diff --git a/.github/workflows/bobcat-samples.yml b/.github/workflows/bobcat-samples.yml new file mode 100644 index 000000000..e294bca55 --- /dev/null +++ b/.github/workflows/bobcat-samples.yml @@ -0,0 +1,56 @@ +name: bobcat-samples + +# GH-5082: the Bobcat samples (src/Samples/Bobcat) moved here from the Bobcat repository with the Critter Stack +# vocabulary. They are not in wolverine.slnx, so no other job touches them. This one builds every sample project and +# runs the specs of every runnable one through the Nuke build's BobcatSamples target (build/BobcatSamples.cs), which +# is also where the reasoning lives: why building was never enough, the quarantine list and its "stale quarantine +# fails too" rule, the database per sample, and BankAccountES's Fisher leg. `./build.sh BobcatSamples` runs the same +# thing locally; it starts the docker-compose Postgres (5433) itself, or reuses one already listening there. + +on: + push: + branches: [ main ] + paths: + - 'src/**' + - 'build/**' + - 'Directory.Packages.props' + - 'NuGet.config' + - 'docker-compose.yml' + - '.github/workflows/bobcat-samples.yml' + pull_request: + branches: [ main ] + paths: + - 'src/**' + - 'build/**' + - 'Directory.Packages.props' + - 'NuGet.config' + - 'docker-compose.yml' + - '.github/workflows/bobcat-samples.yml' + workflow_dispatch: + +env: + # A spec host publishes its progress to a Bobcat monitor when one answers. Nothing listens here. + BOBCAT_MONITOR: "0" + +jobs: + samples: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Build and run every Bobcat sample + run: ./build.sh BobcatSamples + + - name: Stop containers + if: always() + run: docker compose down diff --git a/Directory.Packages.props b/Directory.Packages.props index c784a160c..cb573f8c6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -56,14 +56,16 @@ - + - - - + + + + + diff --git a/build/BobcatSamples.cs b/build/BobcatSamples.cs new file mode 100644 index 000000000..523410bd0 --- /dev/null +++ b/build/BobcatSamples.cs @@ -0,0 +1,172 @@ +using System.Collections.Generic; +using System.Linq; +using Npgsql; +using Nuke.Common; +using Nuke.Common.IO; +using Nuke.Common.Tooling; +using Nuke.Common.Tools.DotNet; +using Serilog; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +// GH-5082: the Bobcat samples (src/Samples/Bobcat), moved here from the Bobcat repository with the Critter Stack +// vocabulary they are written against (JasperFx/bobcat#744), along with the targets that build and run them. +// `./build.sh BobcatSamples` is what bobcat-samples.yml runs. +// +// The samples are not in wolverine.slnx: each has its own .slnx, and they are written as consumers write +// applications. So Compile never touches them, and that gap once let nine of them rot undetected in Bobcat. +// Compiling was never enough to call a sample fixed, either: OutboxDemo compiled clean and could not start +// (Wolverine 6 stopped shipping the runtime compiler), and half the samples sat on an older Marten and died on a +// TypeLoadException the first time they opened a session. So these targets build every sample project AND run the +// specs of every runnable one. +// +// The sample specs are Microsoft.Testing.Platform hosts, so `dotnet test` runs them and one scenario is one test +// node. They need Postgres and nothing else. +partial class Build +{ + AbsolutePath BobcatSamplesDirectory => RootDirectory / "src" / "Samples" / "Bobcat"; + + [Parameter("Postgres the Bobcat sample specs run against, without a database name - each sample gets its own")] + readonly string BobcatSamplesPostgres = "Host=localhost;Port=5433;Username=postgres;Password=postgres"; + + // Sample projects that do not compile today, named individually rather than skipped as a group. Two rules, and + // the second is the one that matters: + // + // * a project outside this list that fails -> new rot, fail the build + // * a project inside this list that BUILDS -> the list is stale, fail the build + // + // A quarantine that only ever forgives is how a permanently-red job becomes background noise. This one has to + // shrink: fix a sample, and the build tells you to delete its line. Paths are relative to the repository root, + // e.g. "src/Samples/Bobcat/OutboxDemo/Tests/Tests.csproj". + static readonly string[] QuarantinedBobcatSamples = + [ + ]; + + // Each runnable sample and the database it uses, matching the default in its own Program.cs. Separate databases + // rather than one shared: MeetingGroupMonolith puts its event store in a schema called `payments`, which is also + // the schema PaymentsMonolith uses for everything, so a shared database would have each one's between-scenario + // reset wiping the other's events. + // + // BankAccountES runs its Marten leg here and its Fisher leg in TestBobcatSamplesOnFisher, which is the point of + // that sample: the same handlers against two stores. + static readonly (string Sample, string Database)[] RunnableBobcatSamples = + [ + ("CqrsMinimalApi", "cqrs_minimal_api"), + ("BookingMonolith", "booking"), + ("CleanArchitectureTodos", "clean_architecture_todos"), + ("MoreSpeakers", "more_speakers"), + ("PaymentsMonolith", "inflow"), + ("EcommerceModularMonolith", "ecommerce"), + ("MeetingGroupMonolith", "meeting_groups"), + ("OutboxDemo", "outbox_demo"), + ("BankAccountES", "bank_account"), + ("EcommerceMicroservices", "ecommerce_catalog"), + ("ProjectManagement", "project_management"), + ]; + + Target CompileBobcatSamples => _ => _ + .Executes(() => + { + var regressions = new List(); + var recovered = new List(); + + foreach (var project in BobcatSamplesDirectory.GlobFiles("**/*.csproj").OrderBy(p => p.ToString())) + { + var name = RootDirectory.GetRelativePathTo(project).ToString().Replace('\\', '/'); + var quarantined = QuarantinedBobcatSamples.Contains(name); + + if (tryRunSample(() => DotNetBuild(s => s.SetProjectFile(project).SetConfiguration(Configuration)))) + { + if (quarantined) + { + Log.Error("{Project} builds now - remove it from QuarantinedBobcatSamples", name); + recovered.Add(name); + } + } + else if (quarantined) + { + Log.Warning("known broken (quarantined): {Project}", name); + } + else + { + Log.Error("{Project} failed to build", name); + regressions.Add(name); + } + } + + Log.Information("quarantined: {Count}", QuarantinedBobcatSamples.Length); + Assert.Empty(regressions, $"Sample(s) newly broken: {string.Join(", ", regressions)}"); + Assert.Empty(recovered, $"Quarantined sample(s) now build - shrink the list: {string.Join(", ", recovered)}"); + }); + + Target TestBobcatSamples => _ => _ + .DependsOn(CompileBobcatSamples) + .Executes(() => + { + // Reused when something already answers on its port (another worktree's stack, a CI service). + StartDockerServices("postgresql"); + + // Created up front because a CI service container only ever makes one database. Tolerates one that + // already exists, so a local rerun works. + using (var connection = new NpgsqlConnection($"{BobcatSamplesPostgres};Database=postgres")) + { + connection.Open(); + foreach (var (_, database) in RunnableBobcatSamples) + { + using var exists = new NpgsqlCommand("select 1 from pg_database where datname = @name", connection); + exists.Parameters.AddWithValue("name", database); + if (exists.ExecuteScalar() != null) continue; + + using var create = new NpgsqlCommand($"create database \"{database}\"", connection); + create.ExecuteNonQuery(); + } + } + + var failed = new List(); + foreach (var (sample, database) in RunnableBobcatSamples) + { + Log.Information("Running the {Sample} specs", sample); + var passed = tryRunSample(() => DotNetTest(s => s + .SetProcessAdditionalArguments("--project", BobcatSamplesDirectory / sample / "Tests") + .SetConfiguration(Configuration) + .SetProcessEnvironmentVariable("ConnectionStrings__Marten", $"{BobcatSamplesPostgres};Database={database}"))); + + if (!passed) failed.Add(sample); + } + + Assert.Empty(failed, $"Sample(s) failed their specs: {string.Join(", ", failed)}"); + }); + + // No services at all - a SQLite file is the whole store, which is the argument for Fisher as the inner-loop + // store, and a sample whose point is "the same handlers run on two stores" is only proved by running both. + Target TestBobcatSamplesOnFisher => _ => _ + .DependsOn(CompileBobcatSamples) + .After(TestBobcatSamples) + .Executes(() => + { + var database = TemporaryDirectory / "bank_account.db"; + database.DeleteFile(); + + DotNetTest(s => s + .SetProcessAdditionalArguments("--project", BobcatSamplesDirectory / "BankAccountES" / "Tests") + .SetConfiguration(Configuration) + .SetProcessEnvironmentVariable("EventStore", "Fisher") + .SetProcessEnvironmentVariable("ConnectionStrings__Fisher", $"Data Source={database}")); + }); + + // What bobcat-samples.yml runs. + Target BobcatSamples => _ => _ + .DependsOn(CompileBobcatSamples, TestBobcatSamples, TestBobcatSamplesOnFisher); + + static bool tryRunSample(System.Action action) + { + try + { + action(); + return true; + } + catch (ProcessException) + { + return false; + } + } +} diff --git a/build/CITargets.cs b/build/CITargets.cs index e07d12362..af65edd6b 100644 --- a/build/CITargets.cs +++ b/build/CITargets.cs @@ -1045,6 +1045,11 @@ void runAwsShard(AbsolutePath project, Func testFilter = null) { var tests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.Tests" / "Wolverine.Bobcat.Tests.csproj"; var httpTests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.Http.Tests" / "Wolverine.Bobcat.Http.Tests.csproj"; + // GH-5082: the store-only Critter Stack vocabulary moved here from Bobcat core, with its tests (Marten on + // Postgres, Fisher on SQLite). [PostgresFact] never skips with CI=true, so a missing database fails. + var storeOnlyTests = RootDirectory / "src" / "Bobcat" / "Bobcat.CritterStackScenario.Tests" / "Bobcat.CritterStackScenario.Tests.csproj"; + // GH-5082: the spec writer (write-specs), which moved here from bobcat import-event-model --specs + var specWriterTests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.EventModel.Tests" / "Wolverine.Bobcat.EventModel.Tests.csproj"; // The samples dogfooding the library. Neither ran in any CI lane before, which is how both // sat unable to start a host (no runtime compiler since GH-2876, and the test assembly @@ -1053,14 +1058,14 @@ void runAwsShard(AbsolutePath project, Func testFilter = null) var processManager = RootDirectory / "src" / "Samples" / "ProcessManagerViaHandlers" / "ProcessManagerViaHandlers.Tests" / "ProcessManagerViaHandlers.Tests.csproj"; - BuildTestProjects(tests, httpTests, incidents, processManager); + BuildTestProjects(tests, httpTests, storeOnlyTests, specWriterTests, incidents, processManager); // The store-agnostic specs (GH-4892, GH-4920, GH-4921) run on Marten, Polecat AND Fisher, and // Polecat needs SQL Server. Without it every Polecat fixture fails to start, which surfaced as a // NullReferenceException in DisposeAsync rather than as the connection failure it was. StartDockerServices("postgresql", "sqlserver"); - RunTestProjects([tests, httpTests, incidents, processManager]); + RunTestProjects([tests, httpTests, storeOnlyTests, specWriterTests, incidents, processManager]); }); Target CIKafka => _ => _ diff --git a/build/build.cs b/build/build.cs index bc44aeb83..c7225e5d2 100644 --- a/build/build.cs +++ b/build/build.cs @@ -370,7 +370,8 @@ partial class Build : NukeBuild Solution.Persistence.Fisher.Wolverine_Fisher, Solution.Persistence.InMemory.Wolverine_InMemory, Solution.Bobcat.Wolverine_Bobcat, - Solution.Bobcat.Wolverine_Bobcat_Http + Solution.Bobcat.Wolverine_Bobcat_Http, + Solution.Bobcat.Wolverine_Bobcat_EventModel }; // GH-3905: a project can declare a and still never ship, because the Pack list above diff --git a/docs/guide/testing.md b/docs/guide/testing.md index e60ff97d3..2737f31bb 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -651,8 +651,9 @@ A few things to know: ### More WolverineFx.Bobcat: several stores, context, and foreign events -`WolverineSpec` and `WolverineScenario` inherit everything Bobcat's `CritterStackSpec` does, and a few of those steps -are worth knowing about from the Wolverine side: +`WolverineSpec` and `WolverineScenario` inherit everything the store-only `CritterStackSpec` does (it ships in WolverineFx.Bobcat +too, under the same `Bobcat.CritterStack` namespace, since it moved out of Bobcat core), and a few of those steps are worth +knowing about from the Wolverine side: * **Several event stores.** On a host with a main store and an ancillary one, such as Marten's `AddMartenStore()`, each Given and Then goes to the store whose registrations claim its type. When the diff --git a/docs/tutorials/event-modeling.md b/docs/tutorials/event-modeling.md index 5368708a1..7c4411f90 100644 --- a/docs/tutorials/event-modeling.md +++ b/docs/tutorials/event-modeling.md @@ -264,14 +264,13 @@ dotnet tool install -g Bobcat.Console ``` Then point it at the board export and the application project. For CritterCrush, we only want two of the board's -chapters, and we want the specifications too: +chapters: ```bash bobcat import-event-model K9CRUSH.emlang.v3.yaml \ --model CritterCrush --namespace CritterCrush \ --out ./CritterCrush \ - --chapter BookingAppointments VolunteeringAndHomeChecks \ - --specs --store marten + --chapter BookingAppointments VolunteeringAndHomeChecks ``` ::: warning @@ -363,8 +362,23 @@ settles it. That's what makes the import usable from an AI agent or from Stoat. ## Generated Specifications -With `--specs`, the import also writes one [WolverineFx.Bobcat](https://bobcat.jasperfx.net/integrations/wolverine) -specification per example on the board into a sibling `CritterCrush.Specs` project, with a folder per chapter. The +Wolverine's `write-specs` command writes one [WolverineFx.Bobcat](https://bobcat.jasperfx.net/integrations/wolverine) +specification per example on the board into a sibling `CritterCrush.Specs` project, with a folder per chapter, and the +spec project itself when there is none yet. It comes with the `WolverineFx.Bobcat.EventModel` package (it was +`bobcat import-event-model --specs` before the spec writer moved to Wolverine). Reference that package from the +application, then run it with the same board and the same flags you gave the import: + +```bash +dotnet run --project ./CritterCrush -- write-specs K9CRUSH.emlang.v3.yaml \ + --model CritterCrush --namespace CritterCrush \ + --out ./CritterCrush \ + --chapter BookingAppointments VolunteeringAndHomeChecks \ + --store marten +``` + +A specification can name a member the board only gives in an example, so the command merges those members into the +stubs the import wrote, the same additive way a re-import does. Running it again adds the specifications for the +board's new examples and changes nothing else. The `AppointmentConfirmed` example from the board becomes this: ```csharp diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/AncillaryStoreHosts.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/AncillaryStoreHosts.cs new file mode 100644 index 000000000..de46e68cf --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/AncillaryStoreHosts.cs @@ -0,0 +1,153 @@ +using Fisher; +using JasperFx; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Marten; +using Microsoft.Extensions.Hosting; + +namespace Bobcat.CritterStack.ScenarioTests; + +// bobcat#504: an application with a main event store and an ancillary one, on Marten and on Fisher. The main +// store holds the Wallet side of the domain, the ancillary store the Invoice side, and both register SharedNote +// so a step about it is ambiguous. + +/// Marten's ancillary store marker, as AddMartenStore<IInvoicingStore>() registers it. +public interface IInvoicingStore : Marten.IDocumentStore; + +/// Fisher's ancillary store marker, as AddFisherStore<IFisherInvoicingStore>() registers it. +public interface IFisherInvoicingStore : Fisher.IDocumentStore; + +/// A host with two event stores, whichever store engine. +public abstract class TwoStoreHost : StoreHost +{ + /// The ancillary store's IEventStore.Identity name: its marker's type name, as the store folds it. + public abstract string AncillaryName { get; } +} + +public sealed class MartenTwoStoreHost : TwoStoreHost +{ + private static int _hosts; + private readonly string _schema = $"bobcat_cs_two_{Interlocked.Increment(ref _hosts)}"; + + public override string AncillaryName => "iinvoicingstore"; + + public override bool IsAvailable => PostgresEnvironment.IsCi || PostgresEnvironment.IsAvailable; + + public override string SkipReason => PostgresEnvironment.SkipReason; + + protected override IHost Build() + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + builder.Services.AddMarten(opts => + { + opts.Connection(PostgresEnvironment.ConnectionString); + opts.DatabaseSchemaName = _schema; + opts.AutoCreateSchemaObjects = AutoCreate.All; + opts.DisableNpgsqlLogging = true; + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + opts.Schema.For(); + }) + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + builder.Services.AddMartenStore(opts => + { + opts.Connection(PostgresEnvironment.ConnectionString); + opts.DatabaseSchemaName = _schema + "_inv"; + opts.AutoCreateSchemaObjects = AutoCreate.All; + opts.DisableNpgsqlLogging = true; + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + opts.Schema.For(); + }) + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + return builder.Build(); + } + + protected override async Task BeforeStartAsync() + { + await MartenDdl.Gate.WaitAsync(); + await dropSchemasAsync(); + } + + protected override void AfterStartAttempt() => MartenDdl.Gate.Release(); + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (IsAvailable) await dropSchemasAsync(); + } + + private async Task dropSchemasAsync() + { + await using var connection = new Npgsql.NpgsqlConnection(PostgresEnvironment.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"DROP SCHEMA IF EXISTS {_schema} CASCADE; DROP SCHEMA IF EXISTS {_schema}_inv CASCADE"; + await command.ExecuteNonQueryAsync(); + } +} + +public sealed class FisherTwoStoreHost : TwoStoreHost +{ + private readonly string _main = Path.Combine(Path.GetTempPath(), $"bobcat-two-main-{Guid.NewGuid():N}.db"); + private readonly string _invoicing = Path.Combine(Path.GetTempPath(), $"bobcat-two-invoicing-{Guid.NewGuid():N}.db"); + + public override string AncillaryName => "ifisherinvoicingstore"; + + protected override IHost Build() + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + builder.Services.AddFisher(opts => + { + opts.Connection($"Data Source={_main}"); + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + opts.Schema.For(); + }) + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + builder.Services.AddFisherStore(opts => + { + opts.Connection($"Data Source={_invoicing}"); + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + opts.Schema.For(); + }) + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + return builder.Build(); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + + foreach (var file in new[] { _main, _invoicing }.SelectMany(x => new[] { x, x + "-wal", x + "-shm" })) + { + try + { + File.Delete(file); + } + catch (IOException) + { + // A temp file; the OS reclaims it + } + } + } +} + +/// One Marten host building its schema at a time, across every host type: Postgres races on its own catalog. +internal static class MartenDdl +{ + public static readonly SemaphoreSlim Gate = new(1, 1); +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/Bobcat.CritterStackScenario.Tests.csproj b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Bobcat.CritterStackScenario.Tests.csproj new file mode 100644 index 000000000..d3ba00276 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Bobcat.CritterStackScenario.Tests.csproj @@ -0,0 +1,62 @@ + + + + + Exe + false + true + net9.0;net10.0 + false + + 13 + + $(NoWarn);NU1202;NETSDK1005;CS0419;xUnit1051;VSTHRD110;CS8981 + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + Servers.cs + + + + diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/ConfiguredShardWaitTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/ConfiguredShardWaitTests.cs new file mode 100644 index 000000000..b0ff000ad --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/ConfiguredShardWaitTests.cs @@ -0,0 +1,83 @@ +using JasperFx.Events; +using JasperFx.Events.Projections; +using JasperFx.Events.Subscriptions; +using NSubstitute; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// A projection wait decides WHAT to wait on from the store's configured shards +/// (IEventStore<,>.AllShards()) rather than from the progress table, because a daemon +/// writes a shard's progress row only after its first batch — right after the first append an empty +/// table looks exactly like "no async projections", and a wait keyed on it passes vacuously. That +/// was seen for real against Marten (see ). +/// +public class ConfiguredShardWaitTests +{ + public class Account; + + [Fact] + public async Task an_empty_progress_table_is_not_mistaken_for_no_projections() + { + var (store, _) = storeWithShards(progress: [], "Account:All"); + + var ex = await Should.ThrowAsync( + () => EventStores.WaitForProjectionAsync(store, minSequence: 3, timeout: TimeSpan.FromMilliseconds(150))); + + ex.Message.ShouldContain("'Account:All' at 0"); + } + + [Fact] + public async Task no_configured_shards_means_nothing_to_wait_for() + { + var (store, database) = storeWithShards(progress: []); + + await EventStores.WaitForProjectionAsync(store, minSequence: 99, timeout: TimeSpan.FromMilliseconds(150)); + + await database.DidNotReceive().AllProjectionProgress(Arg.Any()); + } + + [Fact] + public async Task a_name_matching_no_configured_shard_is_a_wiring_error() + { + var (store, _) = storeWithShards(progress: [], "Orders:All", "Billing:All"); + + var ex = await Should.ThrowAsync( + () => EventStores.WaitForProjectionAsync(store, minSequence: 1, timeout: TimeSpan.FromMilliseconds(150))); + + ex.Message.ShouldContain("'Orders:All'"); + ex.Message.ShouldContain("'Billing:All'"); + } + + [Fact] + public async Task returns_once_the_configured_shard_reports_the_sequence() + { + var (store, _) = storeWithShards(progress: [new ShardState("Account:All", 5), new ShardState("Other:All", 0)], "Account:All", "Other:All"); + + await EventStores.WaitForProjectionAsync(store, minSequence: 5, timeout: TimeSpan.FromMilliseconds(500)); + } + + private static (IEventStore Store, IEventDatabase Database) storeWithShards(IReadOnlyList progress, params string[] shardIdentities) + { + var database = Substitute.For(); + database.Identifier.Returns("main"); + database.AllProjectionProgress(Arg.Any()).Returns(Task.FromResult(progress)); + + var store = Substitute.For>(); + store.AllDatabases().Returns(new ValueTask>([database])); + + IReadOnlyList> shards = shardIdentities + .Select(identity => identity.Split(':')) + .Select(parts => new AsyncShard( + new AsyncOptions(), + default, + new ShardName(parts[0], parts[1], 1), + Substitute.For>(), + null!)) + .ToList(); + store.AllShards().Returns(shards); + + return (store, database); + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/Domain.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Domain.cs new file mode 100644 index 000000000..be6c1199d --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Domain.cs @@ -0,0 +1,142 @@ +namespace Bobcat.CritterStack.ScenarioTests; + +// The domain the store-only vocabulary is specified against. Nothing here knows a store or a message bus: +// the hosts register it on Marten and on Fisher, and every act is a delegate over the host's services. + +public record WalletOpened(Guid WalletId, string Owner); + +public record WalletCredited(Guid WalletId, decimal Amount); + +public record WalletDebited(Guid WalletId, decimal Amount); + +/// A strong-typed id around the Guid a stream is identified by (wolverine#4865). +public readonly record struct WalletId(Guid Value); + +/// The write model: an inline snapshot, folded from its stream. +public class Wallet +{ + public Guid Id { get; set; } + public string Owner { get; set; } = ""; + public decimal Balance { get; set; } + + public void Apply(WalletOpened e) + { + Id = e.WalletId; + Owner = e.Owner; + } + + public void Apply(WalletCredited e) => Balance += e.Amount; + public void Apply(WalletDebited e) => Balance -= e.Amount; +} + +/// An async-projected read model, so the projection waits have a daemon to wait on. +public class WalletSummary +{ + public Guid Id { get; set; } + public int Credits { get; set; } + public decimal Balance { get; set; } + + public void Apply(WalletOpened e) => Id = e.WalletId; + + public void Apply(WalletCredited e) + { + Credits++; + Balance += e.Amount; + } + + public void Apply(WalletDebited e) => Balance -= e.Amount; +} + +/// A plain document, stored directly — the read-model arrange and the document reads. +public class Customer +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public string City { get; set; } = ""; +} + +/// A document type nothing ever stores, for "there is no T at all". +public class Ghost +{ + public Guid Id { get; set; } +} + +// ---- Dynamic Consistency Boundary tags (wolverine#4865) --------------------------------------- + +public readonly record struct ScreeningId(Guid Value); + +public record ScreeningScheduled(ScreeningId Screening, int Seats); + +public record SeatReserved(ScreeningId Screening, string Seat); + +public class SeatAvailability +{ + public Guid Id { get; set; } + public int Seats { get; set; } + + public void Apply(ScreeningScheduled e) => Seats = e.Seats; + public void Apply(SeatReserved e) => Seats--; +} + +/// A refusal an application throws, wrapped by its plumbing before the act sees it. +public class WalletFrozen(Guid walletId) : Exception($"The wallet {walletId} is frozen") +{ + public Guid WalletId { get; } = walletId; +} + +// ---- an ancillary store's side of the domain (bobcat#504) --------------------------------------- + +public record InvoiceRaised(Guid InvoiceId, decimal Amount); + +public record InvoicePaid(Guid InvoiceId); + +/// The ancillary store's write model: an inline snapshot there, registered nowhere else. +public class Invoice +{ + public Guid Id { get; set; } + public decimal Amount { get; set; } + public bool Paid { get; set; } + + public void Apply(InvoiceRaised e) + { + Id = e.InvoiceId; + Amount = e.Amount; + } + + public void Apply(InvoicePaid e) => Paid = true; +} + +/// The ancillary store's async read model, so a wait has a second daemon to cover. +public class InvoiceSummary +{ + public Guid Id { get; set; } + public decimal Raised { get; set; } + public bool Paid { get; set; } + + public void Apply(InvoiceRaised e) + { + Id = e.InvoiceId; + Raised += e.Amount; + } + + public void Apply(InvoicePaid e) => Paid = true; +} + +/// A document both stores register, so a step about it cannot be routed by type. +public class SharedNote +{ + public Guid Id { get; set; } + public string Text { get; set; } = ""; +} + +/// A document no store registers, and nothing ever stores. +public class Unclaimed +{ + public Guid Id { get; set; } +} + +/// A document no store registers until the default store is handed one. +public class Stray +{ + public Guid Id { get; set; } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoreAuthoringIsPublicTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoreAuthoringIsPublicTests.cs new file mode 100644 index 000000000..7bc5e8fdf --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoreAuthoringIsPublicTests.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Bobcat.CritterStack; +using JasperFx.Events; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// is part of the public store vocabulary, because a projected +/// spec has to reach it (issue #324). +/// +/// +/// +/// This test cannot fail from inside this assembly the way a consumer would experience it — +/// InternalsVisibleTo points here, so the type was always reachable from these tests even +/// while it was internal. That is precisely why the regression went unnoticed: every test +/// that used it passed. So the assertion is on the REFLECTED accessibility rather than on the call +/// compiling, which is the thing an outside consumer actually depends on. +/// +/// +/// The failure it guards is not hypothetical. CritterCrush's Lane A rebuild could not arrange a +/// stream from a plain xUnit test, and the compiler said CS0122: 'EventStoreAuthoring' is +/// inaccessible due to its protection level. +/// +/// +public class EventStoreAuthoringIsPublicTests +{ + [Fact] + public void the_type_is_visible_outside_this_assembly() + { + typeof(EventStoreAuthoring).IsPublic + .ShouldBeTrue("a projected spec outside Bobcat.CritterStack must be able to name it"); + } + + [Fact] + public void both_operations_are_callable_and_need_nothing_but_an_event_store() + { + // The whole point: no IStepContext, no fixture, no run in either signature — otherwise the + // type being public buys the projected lane nothing. + var append = typeof(EventStoreAuthoring).GetMethod(nameof(EventStoreAuthoring.AppendAsync)); + var load = typeof(EventStoreAuthoring).GetMethod(nameof(EventStoreAuthoring.LoadDocumentAsync)); + + append.ShouldNotBeNull(); + load.ShouldNotBeNull(); + + // NOT MethodInfo.IsPublic — that is true for a public method on an internal type, so it + // would pass in exactly the state this file exists to prevent. Reachability is the TYPE's + // property, asserted above; what belongs here is the shape of the signatures. + append!.GetParameters()[0].ParameterType.ShouldBe(typeof(IEventStore)); + load!.GetParameters()[0].ParameterType.ShouldBe(typeof(IEventStore)); + + foreach (var method in new[] { append, load }) + { + method.GetParameters() + .ShouldAllBe(p => !p.ParameterType.FullName!.Contains("IStepContext"), + $"{method.Name} must stay reachable without a Bobcat run"); + } + } + + [Fact] + public void the_whole_store_vocabulary_tier_is_public_together() + { + // EventStoreAuthoring was the lone internal beside these, which is what made it read as an + // oversight rather than a boundary. Pinned so the tier does not drift apart again. + foreach (var type in new[] + { + typeof(EventStores), typeof(DocumentStores), + typeof(RecordBuilding), typeof(EventStoreAuthoring) + }) + { + type.IsPublic.ShouldBeTrue($"{type.Name} is part of the public store vocabulary"); + } + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoresTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoresTests.cs new file mode 100644 index 000000000..433564136 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoresTests.cs @@ -0,0 +1,446 @@ +using JasperFx.Events; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using NSubstitute; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// The store-agnostic binding against fakes shaped like the three stores, so the two convention +/// paths (aggregate through a session's Events; reset through Advanced) are proven +/// without referencing Marten, Polecat or Fisher. The Marten leg is +/// . +/// +public class EventStoresTests +{ + public class Account + { + public Guid Id { get; set; } + } + + /// A session as the stores shape one: IStorageOperations with an Events member. + public interface IFakeSession : IStorageOperations + { + IQueryEventStore Events { get; } + } + + // --- aggregate ---------------------------------------------------------------------------- + + [Fact] + public async Task aggregates_through_the_read_only_view_when_it_is_a_query_event_store() + { + // Marten and Polecat: OpenReadOnlyEventStore() returns the session's IQueryEventStore. + var id = Guid.NewGuid(); + var expected = new Account { Id = id }; + var view = Substitute.For(); + view.AggregateStreamAsync(id, token: Arg.Any()).Returns(expected); + + var store = new FakeEventStore { ReadOnlyView = (IReadOnlyEventStore)view }; + + var aggregate = await EventStores.AggregateStreamAsync(store, id); + + aggregate.ShouldBeSameAs(expected); + } + + [Fact] + public async Task falls_back_to_a_session_opened_through_the_generic_closure() + { + // Fisher: the read-only view is a separate object, so a session is opened through + // IEventStore.OpenSession and its Events member is used. + var id = Guid.NewGuid(); + var expected = new Account { Id = id }; + + var events = Substitute.For(); + events.AggregateStreamAsync(id, token: Arg.Any()).Returns(expected); + var session = Substitute.For(); + session.Events.Returns(events); + + var database = Substitute.For(); + var store = Substitute.For>(); + store.AllDatabases().Returns(new ValueTask>([database])); + store.OpenReadOnlyEventStore().Returns(Substitute.For()); + store.OpenSession(database).Returns(session); + + var aggregate = await EventStores.AggregateStreamAsync(store, id); + + aggregate.ShouldBeSameAs(expected); + await session.Received(1).DisposeAsync(); + } + + // --- a tenant's session (bobcat#536) ------------------------------------------------------ + + [Fact] + public async Task a_tenant_session_is_opened_for_the_tenant_on_the_database_the_store_finds_for_it() + { + var id = Guid.NewGuid(); + var expected = new Account { Id = id }; + + var events = Substitute.For(); + events.AggregateStreamAsync(id, token: Arg.Any()).Returns(expected); + var session = Substitute.For(); + session.Events.Returns(events); + + var first = Substitute.For(); + var second = Substitute.For(); + var store = Substitute.For>(); + store.AllDatabases().Returns(new ValueTask>([first, second])); + store.FindDatabaseForTenantAsync("red", Arg.Any()).Returns(new ValueTask(second)); + store.OpenSession(second, "red").Returns(session); + + var aggregate = await EventStores.AggregateStreamAsync(store, id, "red"); + + aggregate.ShouldBeSameAs(expected); + store.DidNotReceive().OpenSession(Arg.Any()); + store.DidNotReceive().OpenSession(first, Arg.Any()); + await session.Received(1).DisposeAsync(); + } + + [Fact] + public async Task a_tenant_on_a_store_with_one_database_is_opened_on_that_database() + { + var only = Substitute.For(); + var session = Substitute.For(); + session.Events.Returns(Substitute.For()); + var store = Substitute.For>(); + store.AllDatabases().Returns(new ValueTask>([only])); + store.FindDatabaseForTenantAsync("red", Arg.Any()).Returns(new ValueTask((IEventDatabase?)null)); + store.OpenSession(only, "red").Returns(session); + + await EventStores.FetchStreamAsync(store, Guid.NewGuid(), "red"); + + store.Received(1).OpenSession(only, "red"); + } + + [Fact] + public async Task a_tenant_no_database_can_be_found_for_is_refused_rather_than_guessed() + { + var store = Substitute.For>(); + store.AllDatabases().Returns(new ValueTask>( + [Substitute.For(), Substitute.For()])); + store.FindDatabaseForTenantAsync("green", Arg.Any()).Returns(new ValueTask((IEventDatabase?)null)); + + var ex = await Should.ThrowAsync( + () => EventStores.FetchStreamAsync(store, Guid.NewGuid(), "green")); + + ex.Message.ShouldContain("'green'"); + ex.Message.ShouldContain("2 databases"); + store.DidNotReceive().OpenSession(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task no_tenant_opens_the_default_session_as_before() + { + var database = Substitute.For(); + var session = Substitute.For(); + session.Events.Returns(Substitute.For()); + var store = Substitute.For>(); + store.AllDatabases().Returns(new ValueTask>([database])); + store.OpenReadOnlyEventStore().Returns(Substitute.For()); + + await EventStores.FetchStreamAsync(store, Guid.NewGuid(), (string?)null); + + store.Received(1).OpenReadOnlyEventStore(); + store.DidNotReceive().OpenSession(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task explains_when_no_session_can_be_opened() + { + var store = Substitute.For>(); + store.AllDatabases().Returns(new ValueTask>(Array.Empty())); + store.OpenReadOnlyEventStore().Returns(Substitute.For()); + + var ex = await Should.ThrowAsync( + () => EventStores.AggregateStreamAsync(store, Guid.NewGuid())); + + ex.Message.ShouldContain("enumerates no databases"); + } + + [Fact] + public void a_session_without_a_query_event_store_is_reported_not_guessed() + { + var ex = Should.Throw( + () => EventStoreSessions.QueryEventStoreOf(new object())); + + ex.Message.ShouldContain("no public 'Events' member"); + } + + /// A session as the stores shape one, with the full event operations FetchLatest is on. + public interface IFakeOperationsSession : IStorageOperations + { + IEventStoreOperations Events { get; } + } + + [Fact] + public async Task fetches_the_latest_aggregate_through_the_sessions_event_operations() + { + // wolverine#4921: FetchLatest, the store's own answer whatever the projection lifecycle + var id = Guid.NewGuid(); + var expected = new Account { Id = id }; + + var events = Substitute.For(); + events.FetchLatest(id, Arg.Any()).Returns(new ValueTask(expected)); + var session = Substitute.For(); + session.Events.Returns(events); + + var database = Substitute.For(); + var store = Substitute.For>(); + store.AllDatabases().Returns(new ValueTask>([database])); + store.OpenSession(database).Returns(session); + + (await EventStores.FetchLatestAsync(store, id)).ShouldBeSameAs(expected); + await session.Received(1).DisposeAsync(); + } + + [Fact] + public void a_session_without_event_operations_is_reported_not_guessed() + { + Should.Throw(() => EventStoreSessions.EventStoreOperationsOf(new object())) + .Message.ShouldContain("IEventStoreOperations"); + } + + // --- reset convention --------------------------------------------------------------------- + + [Fact] + public async Task reset_prefers_ResetAllData() + { + var store = new StoreWithResetAllData(); + await StoreResetConvention.ResetAsync(store, CancellationToken.None); + store.Advanced.Calls.ShouldBe(1); + } + + [Fact] + public async Task reset_accepts_the_Async_suffixed_spelling() + { + // Fisher spells it ResetAllDataAsync. + var store = new StoreWithResetAllDataAsync(); + await StoreResetConvention.ResetAsync(store, CancellationToken.None); + store.Advanced.Calls.ShouldBe(1); + } + + [Fact] + public async Task reset_falls_back_to_the_cleaner_pair() + { + var store = new StoreWithCleanerOnly(); + await StoreResetConvention.ResetAsync(store, CancellationToken.None); + store.Advanced.Clean.Calls.ShouldBe(["events", "documents"]); + } + + [Fact] + public async Task reset_says_what_it_looked_for_when_nothing_matches() + { + var ex = await Should.ThrowAsync( + () => StoreResetConvention.ResetAsync(new FakeEventStore(), CancellationToken.None)); + + ex.Message.ShouldContain("Advanced.ResetAllData(CancellationToken)"); + ex.Message.ShouldContain("DeleteAllEventDataAsync"); + ex.Message.ShouldContain("reset hook"); + } + + // --- projection waits ---------------------------------------------------------------------- + + [Fact] + public async Task no_async_shards_means_nothing_to_wait_for() + { + var database = databaseWithProgress([]); + var store = new FakeEventStore { Databases = [database] }; + + await EventStores.WaitForProjectionAsync(store, minSequence: 10, timeout: TimeSpan.FromMilliseconds(200)); + } + + [Fact] + public async Task a_name_matching_no_shard_fails_loudly_and_lists_the_shards() + { + var database = databaseWithProgress([new ShardState("Orders:All", 5), new ShardState("Billing:All", 5)]); + var store = new FakeEventStore { Databases = [database] }; + + var ex = await Should.ThrowAsync( + () => EventStores.WaitForProjectionAsync(store, minSequence: 1, timeout: TimeSpan.FromMilliseconds(200))); + + ex.Message.ShouldContain("matches 'Account'"); + ex.Message.ShouldContain("'Orders:All'"); + ex.Message.ShouldContain("'Billing:All'"); + } + + [Fact] + public async Task waits_until_the_matching_shards_reach_the_sequence() + { + var calls = 0; + var database = Substitute.For(); + database.AllProjectionProgress(Arg.Any()).Returns(_ => + { + calls++; + IReadOnlyList progress = + [ + new ShardState(ShardState.HighWaterMark, 10), + new ShardState("Account:All", calls >= 3 ? 10 : 4), + new ShardState("Other:All", 1), // never catches up, and must not be waited on + ]; + return Task.FromResult(progress); + }); + var store = new FakeEventStore { Databases = [database] }; + + await EventStores.WaitForProjectionAsync(store, minSequence: 10, timeout: TimeSpan.FromSeconds(5)); + + calls.ShouldBeGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task times_out_with_the_shard_positions() + { + var database = databaseWithProgress([new ShardState("Account:All", 2)]); + var store = new FakeEventStore { Databases = [database] }; + + var ex = await Should.ThrowAsync( + () => EventStores.WaitForProjectionAsync(store, minSequence: 9, timeout: TimeSpan.FromMilliseconds(150))); + + ex.Message.ShouldContain("'Account:All' at 2"); + } + + [Fact] + public async Task the_target_without_a_sequence_is_the_stores_highest_event() + { + var database = databaseWithProgress([new ShardState("Account:All", 7)]); + database.FetchHighestEventSequenceNumber(Arg.Any()).Returns(7L); + var store = new FakeEventStore { Databases = [database] }; + + await EventStores.WaitForProjectionAsync(store, timeout: TimeSpan.FromMilliseconds(200)); + } + + [Fact] + public async Task non_stale_wait_goes_through_every_database() + { + var first = Substitute.For(); + var second = Substitute.For(); + var store = new FakeEventStore { Databases = [first, second] }; + + await EventStores.WaitForNonStaleProjectionsAsync(store, TimeSpan.FromSeconds(1)); + + await first.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1)); + await second.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1)); + } + + // --- one tenant (wolverine#4962 phase 2, jasperfx#1005) ---------------------------------- + + /// A database-per-tenant store that can say which database holds a tenant. + private sealed class TenantedEventStore : FakeEventStore, IEventStore + { + public Dictionary Tenants { get; } = new(); + public List Asked { get; } = new(); + + ValueTask IEventStore.FindDatabaseForTenantAsync(string tenantId, CancellationToken token) + { + Asked.Add(tenantId); + return ValueTask.FromResult(Tenants.GetValueOrDefault(tenantId)); + } + } + + [Fact] + public async Task a_tenant_wait_waits_on_only_the_database_the_store_finds_for_it() + { + var red = Substitute.For(); + var blue = Substitute.For(); + var store = new TenantedEventStore { Databases = [red, blue] }; + store.Tenants["red"] = red; + + await EventStores.WaitForNonStaleProjectionsAsync(store, "red", TimeSpan.FromSeconds(1)); + + store.Asked.ShouldBe(["red"]); + await red.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1)); + await blue.DidNotReceive().WaitForNonStaleProjectionDataAsync(Arg.Any()); + } + + [Fact] + public async Task a_tenant_the_store_cannot_place_waits_on_every_database() + { + var red = Substitute.For(); + var blue = Substitute.For(); + var store = new TenantedEventStore { Databases = [red, blue] }; + + await EventStores.WaitForNonStaleProjectionsAsync(store, "green", TimeSpan.FromSeconds(1)); + + store.Asked.ShouldBe(["green"]); + await red.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1)); + await blue.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task a_single_database_store_answers_a_tenant_through_the_default_lookup() + { + // FakeEventStore keeps the interface's default FindDatabaseForTenantAsync: Single cardinality, one database + var only = Substitute.For(); + var store = new FakeEventStore { Databases = [only] }; + + (await ((IEventStore)store).FindDatabaseForTenantAsync("anyone")).ShouldBeSameAs(only); + + await EventStores.WaitForNonStaleProjectionsAsync(store, "anyone", TimeSpan.FromSeconds(1)); + + await only.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task non_stale_wait_falls_back_to_the_coordinator_then_explains() + { + var store = new FakeEventStore(); + var daemon = Substitute.For(); + var coordinator = Substitute.For(); + coordinator.DaemonForMainDatabase().Returns(daemon); + + await EventStores.WaitForNonStaleProjectionsAsync(store, TimeSpan.FromSeconds(1), coordinator); + await daemon.Received(1).WaitForNonStaleData(TimeSpan.FromSeconds(1)); + + var ex = await Should.ThrowAsync( + () => EventStores.WaitForNonStaleProjectionsAsync(store, TimeSpan.FromSeconds(1))); + ex.Message.ShouldContain("IProjectionCoordinator"); + } + + private static IEventDatabase databaseWithProgress(IReadOnlyList progress) + { + var database = Substitute.For(); + database.Identifier.Returns("main"); + database.AllProjectionProgress(Arg.Any()).Returns(Task.FromResult(progress)); + return database; + } + + // --- fakes shaped like the stores' public surface -------------------------------------------- + + private sealed class StoreWithResetAllData : FakeEventStore + { + public Ops Advanced { get; } = new(); + + public sealed class Ops + { + public int Calls { get; private set; } + public Task ResetAllData(CancellationToken token) { Calls++; return Task.CompletedTask; } + } + } + + private sealed class StoreWithResetAllDataAsync : FakeEventStore + { + public Ops Advanced { get; } = new(); + + public sealed class Ops + { + public int Calls { get; private set; } + public Task ResetAllDataAsync(CancellationToken token = default) { Calls++; return Task.CompletedTask; } + } + } + + private sealed class StoreWithCleanerOnly : FakeEventStore + { + public Ops Advanced { get; } = new(); + + public sealed class Ops + { + public Cleaner Clean { get; } = new(); + } + + public sealed class Cleaner + { + public List Calls { get; } = []; + public Task DeleteAllEventDataAsync(CancellationToken token = default) { Calls.Add("events"); return Task.CompletedTask; } + public Task DeleteAllDocumentsAsync(CancellationToken token = default) { Calls.Add("documents"); return Task.CompletedTask; } + } + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventTagQueryDescriptionTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventTagQueryDescriptionTests.cs new file mode 100644 index 000000000..3b7a4bf05 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventTagQueryDescriptionTests.cs @@ -0,0 +1,31 @@ +using Bobcat.Engine; +using JasperFx.Events.Tags; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +// JasperFx/wolverine#4865: a tag query reads by the names its tags were given. Moved here from Bobcat's +// TagsAndStrongTypedIdsTests with EventStores.Describe (GH-5082, JasperFx/bobcat#755). +public class EventTagQueryDescriptionTests +{ + private readonly record struct ScreeningId(Guid Value); + + private readonly record struct SeatCode(string Value); + + private record SeatReserved(ScreeningId Screening, SeatCode Seat); + + private record SeatReleased(SeatCode Seat); + + [Fact] + public void a_query_reads_as_the_tags_it_selects_by_with_any_event_types_it_narrows_to() + { + using var _ = ScenarioRecorder.Begin("Values", "a scenario", publisher: null, runId: Guid.NewGuid()); + var screening = ScenarioValues.Declare(new ScreeningId(Guid.NewGuid()), "theScreeningId"); + var seat = new SeatCode("4C"); + + var query = EventTagQuery.For(screening).AndEventsOfType().Or(seat); + + EventStores.Describe(query) + .ShouldBe("events tagged #theScreeningId (SeatReserved, SeatReleased) or SeatCode(4C)"); + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeEventStore.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeEventStore.cs new file mode 100644 index 000000000..01bc77b4a --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeEventStore.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using JasperFx.Descriptors; +using JasperFx.Events; +using JasperFx.Events.Daemon; +using JasperFx.Events.Descriptors; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// The smallest concrete — every abstract member stubbed — so a test can +/// shape a store's public surface (an Advanced property, say) the way a reflective +/// convention sees it. NSubstitute proxies cannot grow extra public members, which is why this is a +/// class rather than a substitute. +/// +public class FakeEventStore : IEventStore +{ + public FakeEventStore(string name = "fake", string type = "fake") + { + Identity = new EventStoreIdentity(name, type); + } + + public Uri Subject { get; } = new("fake://store"); + public Meter Meter { get; } = new("Bobcat.CritterStack.Tests.Fake"); + public ActivitySource ActivitySource { get; } = new("Bobcat.CritterStack.Tests.Fake"); + public string MetricsPrefix => "fake"; + public DatabaseCardinality DatabaseCardinality => DatabaseCardinality.Single; + public bool HasMultipleTenants => false; + public EventStoreIdentity Identity { get; } + + public IReadOnlyList Databases { get; set; } = []; + public IReadOnlyEventStore ReadOnlyView { get; set; } = Substitute.For(); + + /// What describes the store as (bobcat#504); null by default. + public EventStoreUsage? Usage { get; set; } + + /// How many times the store was asked to describe itself. + public int UsageReads { get; private set; } + + public Task TryCreateUsage(CancellationToken token) + { + UsageReads++; + return Task.FromResult(Usage); + } + + public ValueTask BuildProjectionDaemonAsync(string? tenantIdOrDatabaseIdentifier = null, ILogger? logger = null) + => throw new NotSupportedException(); + + public ValueTask BuildProjectionDaemonAsync(DatabaseId id) => throw new NotSupportedException(); + + public ValueTask> AllDatabases() => ValueTask.FromResult(Databases); + + public IReadOnlyEventStore OpenReadOnlyEventStore() => ReadOnlyView; + + public Task CompactStreamAsync(Guid streamId, CancellationToken token = default) => Task.CompletedTask; + public Task CompactStreamAsync(string streamKey, CancellationToken token = default) => Task.CompletedTask; +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeProducts.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeProducts.cs new file mode 100644 index 000000000..c17fda7ba --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeProducts.cs @@ -0,0 +1,17 @@ +namespace Bobcat.CritterStack.ScenarioTests +{ + /// A marker interface an ancillary store is registered under, like AddMartenStore<T>'s. + public interface IFakeAncillaryStore; + + namespace FakeProduct + { + /// Stands in for Marten.IDocumentStore: the service a product registers its main store under. + public interface IDocumentStore; + } + + namespace OtherProduct + { + /// A second product's IDocumentStore, as Fisher.IDocumentStore beside Marten's. + public interface IDocumentStore; + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/PartialArrangementTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/PartialArrangementTests.cs new file mode 100644 index 000000000..6db877bf5 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/PartialArrangementTests.cs @@ -0,0 +1,124 @@ +using Bobcat.Engine; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// Issue #241: a Given arranges history, and history is arranged by the fields the decision +/// reads — never by every field the event happens to carry. +/// +/// +/// The failure this fixes took out all thirteen scenarios of a scaffolded chapter at once: +/// Cannot build 'HomeCheckAppointmentProposed' from the columns [ownerId, proposedFor], for +/// a six-parameter record. It also put the curated model in an impossible position — elements: +/// hints exist so records are rich, scenario with: exists so specs are pointed, and the first +/// made the second impossible: enriching an event to six honest fields broke every scenario that +/// arranged it with two. +/// +public class PartialArrangementTests +{ + private record Proposed(Guid AppointmentId, Guid OwnerId, Guid ShelterId, Guid DogId, DateTimeOffset ProposedFor); + + private static readonly Guid Owner = Guid.Parse("0e5e0001-0000-0000-0000-000000000001"); + + [Fact] + public void a_given_arranges_the_fields_it_names_and_defaults_the_rest() + { + var built = (Proposed)RecordBuilding.Build(typeof(Proposed), new Dictionary + { + ["OwnerId"] = Owner.ToString(), ["ProposedFor"] = "2026-10-01T15:00:00Z", + }, partial: true); + + built.OwnerId.ShouldBe(Owner); + built.ProposedFor.ShouldBe(DateTimeOffset.Parse("2026-10-01T15:00:00Z")); + + // The scenario said nothing about these. Since bobcat#421 they are fresh ids rather than + // Guid.Empty, so two unspecified ids are never accidentally the same identity. + new[] { built.AppointmentId, built.ShelterId, built.DogId }.ShouldAllBe(x => x != Guid.Empty); + new[] { built.AppointmentId, built.ShelterId, built.DogId }.Distinct().Count().ShouldBe(3); + } + + [Fact] + public void an_act_is_partial_too() + { + // #241 kept the act strict; bobcat#419 relaxed it, as #241's own author preferred. A command + // with a defaulted field is a real thing to specify, and a typo is still refused by name. + var built = (Proposed)RecordBuilding.Build(typeof(Proposed), new Dictionary + { + ["OwnerId"] = Owner.ToString(), + }); + + built.OwnerId.ShouldBe(Owner); + built.DogId.ShouldNotBe(Guid.Empty); + } + + [Fact] + public void a_column_matching_nothing_is_refused_by_name() + { + // The case actually worth failing on. Relaxing the missing-column rule removed the + // accident that used to catch a typo, so it is checked deliberately instead. + var ex = Should.Throw(() => + RecordBuilding.Build(typeof(Proposed), new Dictionary + { + ["OwnerId"] = Owner.ToString(), ["ProposdFor"] = "2026-10-01T15:00:00Z", + }, partial: true)); + + ex.Message.ShouldContain("ProposdFor"); + ex.Message.ShouldContain("the column [ProposdFor] matches nothing on 'Proposed'"); + } + + /// + /// The verb agrees with the count. Only the noun used to be pluralized, so a single bad column + /// read "the column [X] match nothing" — which is the message a reader meets at the exact + /// moment they have mistyped something, so it is the worst place to look careless. + /// + [Fact] + public void the_unmatched_column_message_agrees_in_number() + { + var one = Should.Throw(() => + RecordBuilding.Build(typeof(Proposed), new Dictionary + { + ["OwnerId"] = Owner.ToString(), ["ProposdFor"] = "2026-10-01T15:00:00Z", + }, partial: true)); + + one.Message.ShouldContain("the column [ProposdFor] matches nothing"); + + var many = Should.Throw(() => + RecordBuilding.Build(typeof(Proposed), new Dictionary + { + ["OwnerId"] = Owner.ToString(), ["ProposdFor"] = "x", ["Wieght"] = "y", + }, partial: true)); + + many.Message.ShouldContain("match nothing"); + many.Message.ShouldNotContain("matches nothing"); + } + + [Fact] + public void an_empty_unmatched_cell_is_a_union_table_not_a_typo() + { + // One `Given events for …` table may carry rows of several event types; its header is then + // the union of their fields, blank where a column does not apply to that row. A typo always + // arrives with a value in it, so emptiness is the discriminator. + var built = (Proposed)RecordBuilding.Build(typeof(Proposed), new Dictionary + { + ["OwnerId"] = Owner.ToString(), ["Reason"] = "", + }, partial: true); + + built.OwnerId.ShouldBe(Owner); + } + + [Fact] + public void a_defaulted_parameter_still_wins_over_the_partial_default() + { + // The #177 path is unchanged: an explicit C# default is a value the author chose, and it + // must still beat default(T). + var built = (Sized)RecordBuilding.Build(typeof(Sized), new Dictionary + { + ["Name"] = "n", + }, partial: true); + + built.Count.ShouldBe(3); + } + + private record Sized(string Name, int Count = 3); +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/PostgresEnvironment.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/PostgresEnvironment.cs new file mode 100644 index 000000000..f7199a83d --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/PostgresEnvironment.cs @@ -0,0 +1,79 @@ +using Npgsql; +using Xunit; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// Where the Marten integration tests find their database, and whether one is actually there. +/// +public static class PostgresEnvironment +{ + private static readonly Lazy _available = new(probe, isThreadSafe: true); + + /// + /// This repository's docker-compose Postgres (5433), or whatever ConnectionStrings__postgres / + /// WOLVERINE_POSTGRES name: is the one place that knows. + /// + public static string ConnectionString => IntegrationTests.Servers.PostgresConnectionString; + + /// GitHub Actions (and most CI systems) set CI=true. + public static bool IsCi => + string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase); + + public static bool IsAvailable => _available.Value; + + public static string SkipReason => + $"No Postgres reachable at {describe()}. Run `docker compose up -d postgresql` from the repo root, " + + "or point ConnectionStrings__postgres (or WOLVERINE_POSTGRES) at your own database. (This skip never applies on CI.)"; + + private static bool probe() + { + try + { + var builder = new NpgsqlConnectionStringBuilder(ConnectionString) { Timeout = 3 }; + using var connection = new NpgsqlConnection(builder.ConnectionString); + connection.Open(); + return true; + } + catch + { + return false; + } + } + + /// Host/database only — never echo the password into test output. + private static string describe() + { + try + { + var builder = new NpgsqlConnectionStringBuilder(ConnectionString); + return $"{builder.Host}:{builder.Port}/{builder.Database}"; + } + catch + { + return "the configured connection string"; + } + } +} + +/// +/// A for tests that need a real Postgres. +/// +/// Off CI, a missing database skips the test with an actionable message so a fresh clone +/// still runs green without Docker. On CI the skip is deliberately NOT applied — the workflow +/// provides a Postgres service, and a missing database there has to fail the build rather than +/// quietly report a pass. +/// +public sealed class PostgresFactAttribute : FactAttribute +{ + public PostgresFactAttribute( + [System.Runtime.CompilerServices.CallerFilePath] string? sourceFilePath = null, + [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) + : base(sourceFilePath, sourceLineNumber) + { + if (!PostgresEnvironment.IsCi && !PostgresEnvironment.IsAvailable) + { + Skip = PostgresEnvironment.SkipReason; + } + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingIsPublicApiTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingIsPublicApiTests.cs new file mode 100644 index 000000000..141584b62 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingIsPublicApiTests.cs @@ -0,0 +1,97 @@ +using System.Reflection; +using Bobcat; +using Bobcat.Engine; +using Bobcat.CritterStack; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// Issue #272: is public API, because [IncludeGrammars] is the +/// designed extension point and any custom grammar taking a needs exactly +/// this conversion. +/// +/// +/// The accessibility is asserted by reflection rather than by the tests merely compiling, because +/// this project has InternalsVisibleTo — it would compile against an internal class +/// just as happily, which is how the surface could silently close again. The consumer that matters +/// is outside the Bobcat assemblies and has no such grant. +/// +public class RecordBuildingIsPublicApiTests +{ + // The shape a grammar module written outside Bobcat actually has: a step text, a trailing + // table, and a runtime Type it must turn into an object. Named Parcel rather than Shipment so + // it cannot collide by simple name with the document-lane sample (issue #270) — a {document} + // capture resolves by simple name across the whole compilation, and two of them is BOBCAT012. + public record Parcel(string Origin, string Destination, decimal WeightKg, string? Carrier = null); + + [Fact] + public void RecordBuilding_is_reachable_from_outside_the_assembly() + { + typeof(RecordBuilding).IsPublic.ShouldBeTrue( + "a custom [IncludeGrammars] module lives outside Bobcat and has no InternalsVisibleTo grant"); + } + + [Fact] + public void Build_and_BuildAll_are_both_part_of_that_surface() + { + var members = typeof(RecordBuilding) + .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(m => m.Name) + .ToList(); + + members.ShouldContain(nameof(RecordBuilding.Build)); + members.ShouldContain(nameof(RecordBuilding.BuildAll)); + } + + [Fact] + public void a_custom_grammar_can_build_a_row_the_way_the_shipped_ones_do() + { + var table = new StepTable( + ["Origin", "Destination", "WeightKg"], + [["Dallas", "Austin", "12.5"]]); + + var built = (Parcel)RecordBuilding.BuildAll(typeof(Parcel), table).Single(); + + built.Origin.ShouldBe("Dallas"); + built.Destination.ShouldBe("Austin"); + built.WeightKg.ShouldBe(12.5m); + // A trailing optional needs no column — the same rule the shipped grammars get. + built.Carrier.ShouldBeNull(); + } + + [Fact] + public void the_conventions_a_custom_grammar_inherits_include_the_unmatched_column_message() + { + var table = new StepTable( + ["Origin", "Destination", "WeightKg", "Wieght"], + [["Dallas", "Austin", "12.5", "12.5"]]); + + var ex = Should.Throw(() => + RecordBuilding.BuildAll(typeof(Parcel), table, "Given shipments exist")); + + // The point of sharing the helper: one typo message, not one per consumer's copy. + ex.Message.ShouldContain("Wieght"); + ex.Message.ShouldContain("Given shipments exist"); + } + + [Fact] + public void BuildAll_carries_the_partial_arrange_rule_through_to_every_row() + { + var table = new StepTable( + ["Origin"], + [["Dallas"], ["Austin"]]); + + // Every build is partial since bobcat#419; the flag is kept for compatibility and changes nothing. + RecordBuilding.BuildAll(typeof(Parcel), table).Count.ShouldBe(2); + + var arranged = RecordBuilding.BuildAll(typeof(Parcel), table, partial: true) + .Cast() + .ToList(); + + arranged.Select(s => s.Origin).ShouldBe(["Dallas", "Austin"]); + // A non-nullable string nobody specified is "" since bobcat#421, never a surprise null + arranged.ShouldAllBe(s => s.Destination == ""); + arranged.ShouldAllBe(s => s.Carrier == null); + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingTests.cs new file mode 100644 index 000000000..719959e9f --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingTests.cs @@ -0,0 +1,86 @@ +using Bobcat.Engine; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// bobcat#177 dogfood follow-on: a constructor parameter with a C# default does not need a table +/// column. Stoat's ClaimNode(string NodeClaimId, string Plan, string NodeId, string Agent, +/// TimeSpan Lease, string? Session = null) is the motivating shape — before this, every +/// When ClaimNode is received table had to carry a Session column saying nothing. +/// +public class RecordBuildingTests +{ + private record Claim(string Plan, string Node, string Agent, TimeSpan Lease, string? Session = null); + private record Sized(string Name, int Count = 3, TimeSpan Window = default); + + [Fact] + public void a_defaulted_parameter_needs_no_column() + { + var built = (Claim)RecordBuilding.Build(typeof(Claim), new Dictionary + { + ["Plan"] = "dogfood", ["Node"] = "spec-suite", ["Agent"] = "claude", ["Lease"] = "00:30:00", + }); + + built.ShouldBe(new Claim("dogfood", "spec-suite", "claude", TimeSpan.FromMinutes(30))); + built.Session.ShouldBeNull(); + } + + [Fact] + public void a_supplied_column_still_beats_the_default() + { + var built = (Claim)RecordBuilding.Build(typeof(Claim), new Dictionary + { + ["Plan"] = "dogfood", ["Node"] = "spec-suite", ["Agent"] = "claude", + ["Lease"] = "00:30:00", ["Session"] = "abc-123", + }); + + built.Session.ShouldBe("abc-123"); + } + + [Fact] + public void a_value_type_defaulted_to_default_materializes_the_actual_default() + { + // `TimeSpan Window = default` reports a null DefaultValue through reflection; the builder + // must hand the constructor default(TimeSpan), not null. + var built = (Sized)RecordBuilding.Build(typeof(Sized), new Dictionary + { + ["Name"] = "n", + }); + + built.Count.ShouldBe(3); + built.Window.ShouldBe(TimeSpan.Zero); + } + + [Fact] + public void a_missing_non_defaulted_column_is_defaulted_now_that_an_act_is_partial() + { + // Issue #233 refused this by name; bobcat#419 made the act partial, as #241 preferred. The + // refusal that remains is for a column matching nothing — the typo worth catching. + var claim = (Claim)RecordBuilding.Build(typeof(Claim), new Dictionary { ["Plan"] = "p" }); + + claim.Plan.ShouldBe("p"); + } + + [Fact] + public void the_refusal_names_the_step_when_a_step_asked() + { + // What the reader used to get instead was `NullReferenceException at + // CritterStackFixture.WhenCommandIsReceived`, which names Bobcat's stack, not their spec. + var ex = Should.Throw(() => + RecordBuilding.Build(typeof(Claim), new Dictionary { ["Plann"] = "p" }, "When Claim is received")); + + ex.Message.ShouldStartWith("'When Claim is received': the column [Plann] matches nothing on 'Claim'"); + } + + [Fact] + public void a_field_less_record_needs_no_columns_at_all() + { + // Issue #233's second question, answered yes: `new Beat()` is a perfectly good act, and an + // emlang import carries no field information, so scaffolded scenarios routinely have + // nothing to put in a table. + RecordBuilding.Build(typeof(Beat), new Dictionary()).ShouldBe(new Beat()); + } +} + +public record Beat(); diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/Recordings.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Recordings.cs new file mode 100644 index 000000000..e5a5a416d --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Recordings.cs @@ -0,0 +1,50 @@ +using Bobcat.Monitoring; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// Opens a Bobcat recording around a block of steps, so a test can read back exactly what the specification +/// rendered — each step's keyword, text, cells and verdict — and what reached the monitor wire. +/// +public static class Recordings +{ + public static async Task RecordAsync(Func steps, IMonitorEventSink? sink = null) + { + var recording = ScenarioRecorder.Begin("CritterStackScenario", Guid.NewGuid().ToString(), sink, Guid.NewGuid()); + try + { + await steps(); + } + finally + { + recording.Dispose(); + } + + return recording; + } + + public static IEnumerable Lines(this ScenarioRecorder.Recording recording) + => recording.Steps.Select(x => $"{x.Keyword} {x.Text}"); + + public static ScenarioRecorder.RecordedStep Step(this ScenarioRecorder.Recording recording, string text) + => recording.Steps.Single(x => x.Text == text); +} + +/// Every monitor event a scenario posted, in order. +public sealed class RecordingSink : IMonitorEventSink +{ + private readonly List _events = new(); + + public void Post(MonitorEvent @event) + { + lock (_events) _events.Add(@event); + } + + public IReadOnlyList Events + { + get + { + lock (_events) return _events.ToArray(); + } + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/StoreHosts.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/StoreHosts.cs new file mode 100644 index 000000000..f2dd183ee --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/StoreHosts.cs @@ -0,0 +1,224 @@ +using Fisher; +using JasperFx; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// An application with an event store and nothing else: no Wolverine, no message bus. One per store, shared +/// by a test class; every test uses fresh stream ids, so they never see each other's events. +/// +public abstract class StoreHost : IAsyncLifetime +{ + private IHost? _host; + + /// The host, once started. Null when the store is not available here and the test skips. + public IHost Host => _host ?? throw new InvalidOperationException("The store host did not start"); + + /// Whether this store can run here. Postgres is skipped locally when absent, never on CI. + public virtual bool IsAvailable => true; + + public virtual string SkipReason => ""; + + /// Every error the host logged — the async daemon's included (bobcat#680). + public CapturedErrors Errors { get; } = new(); + + protected abstract IHost Build(); + + public async ValueTask InitializeAsync() + { + if (!IsAvailable) return; + + await BeforeStartAsync(); + try + { + _host = Build(); + await _host.StartAsync(); + await AfterStartAsync(); + } + finally + { + AfterStartAttempt(); + } + } + + /// Runs whether or not the host started. + protected virtual void AfterStartAttempt() + { + } + + protected virtual Task BeforeStartAsync() => Task.CompletedTask; + + protected virtual Task AfterStartAsync() => Task.CompletedTask; + + public virtual async ValueTask DisposeAsync() + { + if (_host is null) return; + + await _host.StopAsync(); + _host.Dispose(); + } +} + +public class MartenStoreHost : StoreHost +{ + private static int _hosts; + + // A schema per host: the test classes' hosts start in parallel, and Marten's DDL races on one schema. + // Dropped before (a previous run's leftovers) and after. + private readonly string _schema = $"bobcat_cs_scenario_{Interlocked.Increment(ref _hosts)}"; + + public override bool IsAvailable => PostgresEnvironment.IsCi || PostgresEnvironment.IsAvailable; + + public override string SkipReason => PostgresEnvironment.SkipReason; + + /// The WalletSummary projection's lifecycle: Async, unless a store with no async projection is wanted. + protected virtual SnapshotLifecycle SummaryLifecycle => SnapshotLifecycle.Async; + + protected override IHost Build() + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + builder.Logging.AddProvider(Errors); + builder.Services.AddMarten(opts => + { + opts.Connection(PostgresEnvironment.ConnectionString); + opts.DatabaseSchemaName = _schema; + opts.AutoCreateSchemaObjects = AutoCreate.All; + opts.DisableNpgsqlLogging = true; + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SummaryLifecycle); + + opts.Events.RegisterTagType("screening").ForAggregate(); + opts.Projections.LiveStreamAggregation(); + }) + // The schema before the daemon starts: applied after, the two race on the same DDL + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + return builder.Build(); + } + + protected override async Task BeforeStartAsync() + { + // One host building its schema at a time: Postgres races on its own catalog (pg_type) when two + // schemas' DDL runs at once (shared with the two-store host, bobcat#504) + await MartenDdl.Gate.WaitAsync(); + await dropSchemaAsync(); + } + + protected override void AfterStartAttempt() => MartenDdl.Gate.Release(); + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (IsAvailable) await dropSchemaAsync(); + } + + private async Task dropSchemaAsync() + { + await using var connection = new Npgsql.NpgsqlConnection(PostgresEnvironment.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"DROP SCHEMA IF EXISTS {_schema} CASCADE"; + await command.ExecuteNonQueryAsync(); + } +} + +public class FisherStoreHost : StoreHost +{ + private readonly string _file = Path.Combine(Path.GetTempPath(), $"bobcat-critterstack-scenario-{Guid.NewGuid():N}.db"); + + /// The WalletSummary projection's lifecycle: Async, unless a store with no async projection is wanted. + protected virtual SnapshotLifecycle SummaryLifecycle => SnapshotLifecycle.Async; + + protected override IHost Build() + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + builder.Logging.AddProvider(Errors); + builder.Services.AddFisher(opts => + { + opts.Connection($"Data Source={_file}"); + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SummaryLifecycle); + + opts.Events.RegisterTagType("screening").ForAggregate(); + opts.Projections.LiveStreamAggregation(); + }) + // Fisher builds its schema lazily, and the daemon reads its progression table on start + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + return builder.Build(); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + + foreach (var file in new[] { _file, _file + "-wal", _file + "-shm" }) + { + try + { + File.Delete(file); + } + catch (IOException) + { + // A temp file; the OS reclaims it + } + } + } +} + +/// bobcat#620: with every projection inline, so the store has no async shard. +public sealed class InlineOnlyMartenStoreHost : MartenStoreHost +{ + protected override SnapshotLifecycle SummaryLifecycle => SnapshotLifecycle.Inline; +} + +/// bobcat#620: with every projection inline, so the store has no async shard. +public sealed class InlineOnlyFisherStoreHost : FisherStoreHost +{ + protected override SnapshotLifecycle SummaryLifecycle => SnapshotLifecycle.Inline; +} + +/// A logger provider keeping every error-level entry, with its exception, for a test to assert on. +public sealed class CapturedErrors : ILoggerProvider +{ + private readonly List _entries = []; + + public IReadOnlyList Entries + { + get + { + lock (_entries) return _entries.ToList(); + } + } + + public ILogger CreateLogger(string categoryName) => new Logger(this, categoryName); + + public void Dispose() + { + } + + private sealed class Logger(CapturedErrors errors, string category) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) return; + + lock (errors._entries) errors._entries.Add($"{category}: {formatter(state, exception)} {exception}"); + } + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/Usings.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Usings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/ancillary_stores.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/ancillary_stores.cs new file mode 100644 index 000000000..1075eb7e5 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/ancillary_stores.cs @@ -0,0 +1,255 @@ +using Bobcat.CritterStack; +using Bobcat.Engine; +using JasperFx.Events; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +// bobcat#504: a specification over an application with a main event store and an ancillary one. Steps go to the +// store whose registrations claim their types (A), In() / In(name) address one explicitly (B), and an act +// is observed on every store (C). Each test runs on Marten and on Fisher through the classes at the bottom. + +public abstract class ancillary_stores(THost host) where THost : TwoStoreHost where TMarker : class +{ + private CritterStackScenario newScenario(string? storeName = null) + { + Assert.SkipUnless(host.IsAvailable, host.SkipReason); + return new CritterStackScenario(host.Host, storeName); + } + + private IEventStore main => host.Host.Services.EventStore("main"); + private IEventStore invoicing => host.Host.Services.EventStore(host.AncillaryName); + + private static Task append(IServiceProvider services, string store, Type aggregate, Guid id, params object[] events) + => EventStoreAuthoring.AppendAsync(services.EventStore(store), aggregate, id, events); + + // ---- A: route by type -------------------------------------------------------------------- + + [Fact] + public async Task givens_and_thens_go_to_the_store_that_registers_their_type() + { + // Every step here names a type exactly one store registers, so the types decide + var scenario = newScenario(); + var wallet = Guid.NewGuid(); + var invoice = Guid.NewGuid(); + var loose = Guid.NewGuid(); + var summary = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(wallet, new WalletOpened(wallet, "Ann")); + await scenario.GivenEventsOn(invoice, new InvoiceRaised(invoice, 40m)); + // No aggregate type: its events say where it goes + await scenario.GivenEvents(loose, new InvoiceRaised(loose, 5m)); + await scenario.GivenReadModel(new InvoiceSummary { Id = summary, Raised = 9m }); + + await scenario.ThenAggregate(invoice, Specifications.Specify().With(x => x.Amount, 40m)); + await scenario.ThenAggregate(wallet, Specifications.Specify().With(x => x.Owner, "Ann")); + await scenario.ThenDocument(summary, Specifications.Specify().With(x => x.Raised, 9m)); + }); + + recording.GatheredFailures().ShouldBeNull(); + + (await EventStores.FetchStreamAsync(invoicing, invoice)).Count.ShouldBe(1); + (await EventStores.FetchStreamAsync(main, invoice)).Count.ShouldBe(0); + (await EventStores.FetchStreamAsync(main, wallet)).Count.ShouldBe(1); + (await EventStores.FetchStreamAsync(invoicing, wallet)).Count.ShouldBe(0); + (await EventStores.FetchStreamAsync(invoicing, loose)).Count.ShouldBe(1); + (await EventStoreAuthoring.LoadDocumentAsync(invoicing, summary)).ShouldNotBeNull(); + (await scenario.TheAggregate(invoice))!.Amount.ShouldBe(40m); + } + + [Fact] + public async Task with_no_store_name_the_main_store_is_the_default() + { + // No storeName: of the two stores only one is the application's main store, so it is the default + var scenario = newScenario(); + scenario.EventStore.ShouldBeSameAs(main); + + var note = Guid.NewGuid(); + var stray = Guid.NewGuid(); + var invoice = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + // Both stores register SharedNote, no store registers Stray: the main store takes both + await scenario.GivenReadModel(new SharedNote { Id = note, Text = "both register it" }); + await scenario.GivenReadModel(new Stray { Id = stray }); + // ...while a type the ancillary store registers still goes there + await scenario.GivenEvents(invoice, new InvoiceRaised(invoice, 1m)); + + await scenario.ThenDocument(note, Specifications.Specify().With(x => x.Text, "both register it")); + await scenario.ThenNoReadModel(); + }); + + recording.GatheredFailures().ShouldBeNull(); + (await EventStoreAuthoring.LoadDocumentAsync(main, note)).ShouldNotBeNull(); + (await EventStoreAuthoring.LoadDocumentAsync(invoicing, note)).ShouldBeNull(); + (await EventStoreAuthoring.LoadDocumentAsync(main, stray)).ShouldNotBeNull(); + (await EventStores.FetchStreamAsync(invoicing, invoice)).Count.ShouldBe(1); + + // A stream named by its id alone has no type to route by: it is read from the main store + (await scenario.TheStreamVersion(invoice)).ShouldBe(0); + (await scenario.In().TheStreamVersion(invoice)).ShouldBe(1); + } + + [Fact] + public async Task an_explicit_store_name_still_chooses_the_default_over_the_main_store() + { + // The ancillary store as the default: SharedNote is claimed by both, but the default can take it... + var scenario = newScenario(host.AncillaryName); + var note = Guid.NewGuid(); + await scenario.GivenReadModel(new SharedNote { Id = note }); + (await EventStoreAuthoring.LoadDocumentAsync(invoicing, note)).ShouldNotBeNull(); + + // ...and a type only the main store claims still goes to the main store + var wallet = Guid.NewGuid(); + await scenario.GivenEvents(wallet, new WalletOpened(wallet, "Ed")); + (await EventStores.FetchStreamAsync(main, wallet)).Count.ShouldBe(1); + } + + [Fact] + public async Task the_default_store_takes_what_no_store_or_several_stores_claim() + { + var scenario = newScenario("main"); + var note = Guid.NewGuid(); + var stray = Guid.NewGuid(); + var invoice = Guid.NewGuid(); + + await scenario.GivenReadModel(new SharedNote { Id = note, Text = "both register it" }); + await scenario.GivenReadModel(new Stray { Id = stray }); + // ...while a type one store registers still goes to that store + await scenario.GivenEvents(invoice, new InvoiceRaised(invoice, 1m)); + + (await EventStoreAuthoring.LoadDocumentAsync(main, note)).ShouldNotBeNull(); + (await EventStoreAuthoring.LoadDocumentAsync(invoicing, note)).ShouldBeNull(); + (await EventStoreAuthoring.LoadDocumentAsync(main, stray)).ShouldNotBeNull(); + (await EventStores.FetchStreamAsync(invoicing, invoice)).Count.ShouldBe(1); + + // A stream named by its id alone is read from the default store; In() names the other + (await scenario.TheStreamVersion(invoice)).ShouldBe(0); + (await scenario.In().TheStreamVersion(invoice)).ShouldBe(1); + } + + // ---- B: explicit addressing -------------------------------------------------------------- + + [Fact] + public async Task in_addresses_a_store_by_marker_or_by_name_within_the_same_specification() + { + var scenario = newScenario(); + var note = Guid.NewGuid(); + var other = Guid.NewGuid(); + var invoice = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.In().GivenReadModel(new SharedNote { Id = note, Text = "invoicing" }); + await scenario.In("main").GivenReadModel(new SharedNote { Id = other, Text = "main" }); + await scenario.In().ThenDocument(note, Specifications.Specify().With(x => x.Text, "invoicing")); + await scenario.In("MAIN").ThenNoReadModel(note); + + // An act through a view is the specification's act, and the scenario judges it + await scenario.In().WhenActing("an invoice is raised", + services => append(services, host.AncillaryName, typeof(Invoice), invoice, new InvoiceRaised(invoice, 2m))); + scenario.ThenEvents(new InvoiceRaised(invoice, 2m)); + await scenario.In(host.AncillaryName).ThenEventsOn(invoice, 0, new InvoiceRaised(invoice, 2m)); + }); + + recording.GatheredFailures().ShouldBeNull(); + // Every step, through a view or not, renders in the one specification + recording.Steps.Count.ShouldBeGreaterThanOrEqualTo(7); + + (await EventStoreAuthoring.LoadDocumentAsync(main, other))!.Text.ShouldBe("main"); + (await scenario.In().TheStreamVersion(invoice)).ShouldBe(1); + scenario.In().EventStore.ShouldBeSameAs(invoicing); + scenario.In(host.AncillaryName).EventStore.ShouldBeSameAs(invoicing); + scenario.In().LastAct.ShouldBeSameAs(scenario.LastAct); + + var unknown = Should.Throw(() => scenario.In("nope")); + unknown.Message.ShouldContain(host.AncillaryName); + } + + // ---- C: every store observed during the act ---------------------------------------------- + + [Fact] + public async Task an_act_on_both_stores_is_observed_on_both_and_reported_by_store() + { + var scenario = newScenario(); + var wallet = Guid.NewGuid(); + var invoice = Guid.NewGuid(); + var started = Guid.NewGuid(); + await scenario.GivenEvents(wallet, new WalletOpened(wallet, "Bo")); + await scenario.GivenEventsOn(invoice, new InvoiceRaised(invoice, 12m)); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the invoice is paid from the wallet", async services => + { + await append(services, "main", typeof(Wallet), wallet, new WalletDebited(wallet, 12m)); + await append(services, host.AncillaryName, typeof(Invoice), invoice, new InvoicePaid(invoice)); + await append(services, host.AncillaryName, typeof(Invoice), started, new InvoiceRaised(started, 3m)); + }); + + scenario.ThenEventsInAnyOrder(new WalletDebited(wallet, 12m), new InvoicePaid(invoice), new InvoiceRaised(started, 3m)); + scenario.ThenEventsOn(wallet, new WalletDebited(wallet, 12m)); + scenario.ThenEventsOn(invoice, new InvoicePaid(invoice)); + await scenario.ThenEventsOn(invoice, 1, new InvoicePaid(invoice)); + await scenario.ThenStreamIsStartedWithEvents(new InvoiceRaised(started, 3m)); + }); + + recording.GatheredFailures().ShouldBeNull(); + scenario.TheStartedStream().ShouldBe(started); + + // Which store each event came from + var act = scenario.LastAct; + act.NewEvents.Count.ShouldBe(3); + act.StoreOf(act.NewEvents.Single(x => x.Data is WalletDebited)).ShouldBeSameAs(main); + act.StoreOf(act.NewEvents.Single(x => x.Data is InvoicePaid)).ShouldBeSameAs(invoicing); + act.StoreOf(act.NewEvents.Single(x => x.Data is InvoiceRaised)).ShouldBeSameAs(invoicing); + + // ...and a failing whole-store step says so + var wrong = await Recordings.RecordAsync(() => + { + scenario.ThenNoEvents(); + return Task.CompletedTask; + }); + var failure = wrong.GatheredFailures().ShouldNotBeNull(); + failure.ShouldContain("Appended by store — 'main': WalletDebited("); + failure.ShouldContain($"'{host.AncillaryName}': InvoicePaid("); + } + + [Fact] + public async Task the_catch_up_waits_on_every_store_and_a_read_on_the_store_it_reads() + { + var scenario = newScenario(); + var wallet = Guid.NewGuid(); + var invoice = Guid.NewGuid(); + + await scenario.WhenActing("a wallet is opened and credited, and an invoice raised", async services => + { + await append(services, "main", typeof(Wallet), wallet, new WalletOpened(wallet, "Cy"), new WalletCredited(wallet, 20m)); + await append(services, host.AncillaryName, typeof(Invoice), invoice, new InvoiceRaised(invoice, 20m)); + }); + + await scenario.AfterAsyncDaemonsCatchUpAsync(); + + // Read straight from each store, with no wait of their own: both daemons have caught up + (await EventStoreAuthoring.LoadDocumentAsync(main, wallet))!.Credits.ShouldBe(1); + (await EventStoreAuthoring.LoadDocumentAsync(invoicing, invoice))!.Raised.ShouldBe(20m); + + // A read waits on the store it reads, which here is the ancillary one + var invoiceId = Guid.NewGuid(); + await scenario.WhenActing("another invoice is raised", + services => append(services, host.AncillaryName, typeof(Invoice), invoiceId, new InvoiceRaised(invoiceId, 4m))); + await scenario.ThenReadModel(invoiceId, Specifications.Specify().With(x => x.Raised, 4m)); + + // A view waits on its own store + await scenario.In().AfterAsyncDaemonsCatchUpAsync(); + } +} + +public class ancillary_stores_on_marten(MartenTwoStoreHost host) + : ancillary_stores(host), IClassFixture; + +public class ancillary_stores_on_fisher(FisherTwoStoreHost host) + : ancillary_stores(host), IClassFixture; diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_scenario.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_scenario.cs new file mode 100644 index 000000000..8162ebae9 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_scenario.cs @@ -0,0 +1,1608 @@ +using Bobcat.CritterStack; +using Bobcat.Engine; +using Bobcat.Monitoring; +using Bobcat.Engine.Verification; +using JasperFx.Events.Tags; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +// bobcat#471, phase 1: every member of the store-only vocabulary, on Marten and on Fisher, with no message +// bus registered anywhere. Each test runs once per store through the two derived classes at the bottom. + +public abstract class critter_stack_scenario(THost store) where THost : StoreHost +{ + private CritterStackScenario newScenario() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + return new CritterStackScenario(store.Host); + } + + // A store-only act: whatever an application does, here an append through the store's own abstractions + private static Func appending(Type aggregate, Guid id, params object[] events) + => services => EventStoreAuthoring.AppendAsync(services.EventStore(), aggregate, id, events); + + private static Func appending(Guid id, params object[] events) + => appending(typeof(Wallet), id, events); + + // ---- arrange --------------------------------------------------------------------------- + + [Fact] + public async Task givens_append_and_read_as_the_wolverine_vocabulary_reads() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + var other = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(id, new WalletOpened(id, "Ann"), new WalletCredited(id, 10)); + await scenario.GivenEventsOn(other, new WalletOpened(other, "Bea")); + await scenario.GivenNoEventsFor(Guid.NewGuid()); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps.Select(x => x.Keyword).ShouldBe(["Given", "And", "And"]); + recording.Steps[0].Text.ShouldStartWith("the Wallet stream has already recorded WalletOpened("); + // bobcat#640: a second stream's name is marked as an alias, so it cannot read as the type twice + recording.Steps[1].Text.ShouldStartWith("Wallet #Wallet2 has already recorded WalletOpened("); + recording.Steps[2].Text.ShouldEndWith("has no events yet"); + + (await scenario.TheAggregate(id))!.Balance.ShouldBe(10); + (await scenario.TheAggregate(other))!.Owner.ShouldBe("Bea"); + (await scenario.TheStreamVersion(id)).ShouldBe(2); + } + + [Fact] + public async Task a_strong_typed_id_and_a_partial_object_arrange_a_stream() + { + var scenario = newScenario(); + var id = new WalletId(Guid.NewGuid()); + + await scenario.GivenEvents(id, + new WalletOpened(id.Value, "Cy"), + Specifications.Specify().With(x => x.WalletId, id.Value).With(x => x.Amount, 7m)); + + (await scenario.TheAggregate(id))!.Balance.ShouldBe(7); + await scenario.ThenAggregate(id, Specifications.Specify().With(x => x.Owner, "Cy")); + } + + [Fact] + public async Task an_aggregate_less_stream_is_arranged_by_its_id_alone() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(() => scenario.GivenEvents(id, new WalletOpened(id, "Di"))); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps.Single().Text.ShouldStartWith("the stream has already recorded WalletOpened("); + (await scenario.TheStreamVersion(id)).ShouldBe(1); + } + + [Fact] + public async Task a_context_stream_is_arranged_exactly_as_given_events_and_never_becomes_the_scenarios_stream() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + var lookup = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(id, new WalletOpened(id, "Ann"), new WalletCredited(id, 3)); + // bobcat#502: a lookup the act reads, arranged after the stream the act decides against + await scenario.GivenContext(lookup, new WalletOpened(lookup, "Bea")); + await scenario.GivenContext(typeof(Wallet), new WalletId(Guid.NewGuid())); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps.Select(x => x.Keyword).ShouldBe(["Given", "And", "And"]); + recording.Steps[1].Text.ShouldContain("has already recorded WalletOpened("); + recording.Steps[2].Text.ShouldEndWith("has no events yet"); + + // The same append GivenEvents makes + (await scenario.TheAggregate(lookup))!.Owner.ShouldBe("Bea"); + (await scenario.TheStreamVersion(lookup)).ShouldBe(1); + + // ...but the stream the scenario is about is still the one GivenEvents named + (await scenario.ThenSingleReadModel()).Balance.ShouldBe(3); + } + + [Fact] + public async Task a_read_models_type_is_inferred_from_the_document() + { + // bobcat#619: no type argument, from a whole document or from a partial one + var scenario = newScenario(); + var whole = Guid.NewGuid(); + var partial = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenReadModel(new Customer { Id = whole, Name = "Ann" }); + await scenario.GivenReadModel(Specifications.Specify().With(x => x.Id, partial).With(x => x.Name, "Bo")); + await scenario.ThenDocument(whole, Specifications.Specify().With(x => x.Name, "Ann")); + await scenario.ThenDocument(partial, Specifications.Specify().With(x => x.Name, "Bo")); + }); + + recording.GatheredFailures().ShouldBeNull(); + + // bobcat#682: "there is a Customer with ...", not "the Customer read model is Customer(...)" + recording.Steps[0].Text.ShouldStartWith("there is a Customer with Id: #"); + recording.Steps[0].Text.ShouldContain("Name: \"Ann\""); + recording.Steps[1].Text.ShouldStartWith("there is a Customer with Id: #"); + recording.Steps[1].Text.ShouldContain("Name: \"Bo\""); + + // ...and two documents checked side by side say which is which + var checks = recording.Steps.Select(x => x.Text).Where(x => x.EndsWith(" matches")).ToList(); + checks.Count.ShouldBe(2); + checks.ShouldAllBe(x => x.StartsWith("the Customer #")); + checks[0].ShouldNotBe(checks[1]); + } + + [Fact] + public async Task a_read_model_is_arranged_directly_and_read_back_as_a_document() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenReadModel(Specifications.Specify().With(x => x.Id, id).With(x => x.Name, "Ed")); + await scenario.ThenDocument(id, Specifications.Specify().With(x => x.Name, "Ed")); + await scenario.ThenReadModel(id); + await scenario.ThenNoReadModel(Guid.NewGuid()); + await scenario.ThenNoReadModel(); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps[0].Text.ShouldStartWith("there is a Customer with Id: #"); + + // A document that is not there is a wrong, not a pass + // A recording of its own starts with no names, so the id is named as the arranged document named it + var missing = await Recordings.RecordAsync(() => + { + ScenarioValues.Learn(id, "Customer"); + return scenario.ThenNoReadModel(id); + }); + missing.GatheredFailures().ShouldNotBeNull().ShouldContain("Expected no Customer document"); + } + + [Fact] + public async Task a_read_model_too_long_for_the_line_keeps_its_values_and_reads_back_by_a_readable_id() + { + // bobcat#618: a class read model rendered "the Customer read model is Customer" with none of its values + // ("there is a Customer" since bobcat#682), + // and a document named after its own type read "there is no Customer Customer" (#Customer since bobcat#640) + var scenario = newScenario(); + var id = Guid.NewGuid(); + var customer = new Customer { Id = id, Name = new string('n', 80), City = "a city with a long enough name" }; + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenReadModel(customer); + await scenario.ThenReadModel(id); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps[0].Text.ShouldBe("there is a Customer"); + recording.Steps[0].Cells.Single(x => x.Name == "values").DisplayText.ShouldContain("City: \"a city with a long enough name\""); + recording.Steps.Select(x => x.Text).ShouldContain("there is a Customer #Customer"); + + // A recording of its own starts with no names, so the id is named as the arranged document named it + var missing = await Recordings.RecordAsync(() => + { + ScenarioValues.Learn(id, "Customer"); + return scenario.ThenNoReadModel(id); + }); + missing.Steps.Select(x => x.Text).ShouldContain("there is no Customer #Customer"); + } + + // ---- the store-only act ------------------------------------------------------------------ + + [Fact] + public async Task a_store_only_act_is_judged_by_what_the_store_gained() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Flo")); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the wallet is credited twice", + appending(id, new WalletCredited(id, 5), new WalletCredited(id, 6))); + + scenario.ThenEvents(typeof(WalletCredited), typeof(WalletCredited)); + scenario.ThenEvents(new WalletCredited(id, 5), new WalletCredited(id, 6)); + scenario.ThenEventsInAnyOrder(new WalletCredited(id, 6), new WalletCredited(id, 5)); + scenario.ThenEventsInAnyOrder(typeof(WalletCredited), typeof(WalletCredited)); + scenario.ThenEmitted(Specifications.Specify().With(x => x.Amount, 6m)); + scenario.ThenEmitted(); + scenario.ThenNotEmitted(); + scenario.ThenNotEmitted(new WalletCredited(id, 99)); + scenario.ThenEventsOn(id, new WalletCredited(id, 5), new WalletCredited(id, 6)); + scenario.ThenNoEventsOn(Guid.NewGuid()); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps[0].Text.ShouldBe("the wallet is credited twice"); + recording.Steps[0].Keyword.ShouldBe("When"); + recording.Steps[1].Text.ShouldBe("WalletCredited, WalletCredited are emitted"); + + scenario.LastAct.Error.ShouldBeNull(); + scenario.LastAct.NewEvents.Count.ShouldBe(2); + scenario.LastAct.AllNewEvents.Count.ShouldBe(2); + scenario.LastAct.MessagesSent.ShouldBeEmpty(); + // bobcat#504: one store, nothing to tell apart + scenario.LastAct.StoreByEvent.ShouldBeEmpty(); + scenario.TheEvents.ShouldBe([new WalletCredited(id, 5), new WalletCredited(id, 6)]); + Should.Throw(() => scenario.TheEvent()).Message.ShouldContain("exactly one"); + + // A wrong expectation is gathered on its step, never thrown, inside a recording + var wrong = await Recordings.RecordAsync(() => + { + scenario.ThenEvents(new WalletCredited(id, 5)); + scenario.ThenEvents(typeof(WalletDebited)); + return Task.CompletedTask; + }); + wrong.Steps.Select(x => x.Status).ShouldBe([ResultStatus.failed, ResultStatus.failed]); + } + + // ---- attribution by stream (bobcat#503) -------------------------------------------------- + + // Another test's append to a stream it arranged itself, landing while this scenario's act runs + private static async Task anotherTestAppends(IServiceProvider services, Guid stream, params object[] events) + => await EventStoreAuthoring.AppendAsync(services.EventStore(), typeof(Wallet), stream, events); + + [Fact] + public async Task a_concurrent_append_to_a_stream_the_spec_never_arranged_is_foreign() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + var elsewhere = Guid.NewGuid(); + await new CritterStackScenario(store.Host).GivenEvents(elsewhere, new WalletOpened(elsewhere, "Other")); + await scenario.GivenEvents(id, new WalletOpened(id, "Ann")); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the wallet is credited while another test writes", async services => + { + await EventStoreAuthoring.AppendAsync(services.EventStore(), typeof(Wallet), id, [new WalletCredited(id, 5)]); + await anotherTestAppends(services, elsewhere, new WalletDebited(elsewhere, 1)); + }); + + // Whole-store steps judge only the specification's own streams + scenario.ThenEvents(new WalletCredited(id, 5)); + scenario.ThenNotEmitted(); + + // A step that names the stream still sees what was appended there + scenario.ThenEventsOn(elsewhere, new WalletDebited(elsewhere, 1)); + }); + + recording.GatheredFailures().ShouldBeNull(); + scenario.LastAct.NewEvents.Select(x => x.Data).ShouldBe([new WalletCredited(id, 5)]); + scenario.LastAct.AllNewEvents.Select(x => x.Data).ShouldBe([new WalletCredited(id, 5)]); + scenario.LastAct.ForeignEvents.Select(x => x.Data).ShouldBe([new WalletDebited(elsewhere, 1)]); + scenario.TheEvents.ShouldBe([new WalletCredited(id, 5)]); + } + + [Fact] + public async Task a_failing_whole_store_assertion_suspects_cross_talk_and_a_passing_one_stays_quiet() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + var elsewhere = Guid.NewGuid(); + await new CritterStackScenario(store.Host).GivenEvents(elsewhere, new WalletOpened(elsewhere, "Other")); + await scenario.GivenEvents(id, new WalletOpened(id, "Ann")); + + await scenario.WhenActing("the wallet is credited while another test writes", async services => + { + await EventStoreAuthoring.AppendAsync(services.EventStore(), typeof(Wallet), id, [new WalletCredited(id, 5)]); + await anotherTestAppends(services, elsewhere, new WalletDebited(elsewhere, 1), new WalletDebited(elsewhere, 2)); + }); + + var passing = await Recordings.RecordAsync(() => + { + scenario.ThenEvents(typeof(WalletCredited)); + return Task.CompletedTask; + }); + passing.GatheredFailures().ShouldBeNull(); + + var failing = await Recordings.RecordAsync(() => + { + scenario.ThenEvents(typeof(WalletDebited)); + scenario.ThenEvents(new WalletCredited(id, 6)); + scenario.ThenNoEvents(); + return Task.CompletedTask; + }); + + var failures = failing.GatheredFailures().ShouldNotBeNull(); + failing.Steps.Select(x => x.Status).ShouldAllBe(x => x == ResultStatus.failed); + failures.ShouldContain("cross-talk suspected: 2 event(s) on streams this specification never arranged or started were appended during the act. Is another test writing to this store concurrently?"); + + // Outside a recording the wrong is thrown, with the same line + Should.Throw(() => scenario.TheEvent()).Message.ShouldContain("cross-talk suspected"); + } + + [Fact] + public async Task a_failing_assertion_with_nothing_foreign_suspects_nothing() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Ann")); + await scenario.WhenActing("the wallet is credited", appending(id, new WalletCredited(id, 5))); + + var failing = await Recordings.RecordAsync(() => + { + scenario.ThenEvents(typeof(WalletDebited)); + return Task.CompletedTask; + }); + + scenario.LastAct.ForeignEvents.ShouldBeEmpty(); + failing.GatheredFailures().ShouldNotBeNull().ShouldNotContain("cross-talk"); + } + + [Fact] + public async Task a_stream_an_earlier_act_started_or_an_act_addressed_is_the_specifications_own() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + var addressed = Guid.NewGuid(); + await new CritterStackScenario(store.Host).GivenEvents(addressed, new WalletOpened(addressed, "Bea")); + + // The first act starts the stream; the second appends to it, and nothing arranged it + await scenario.WhenActing("a wallet is opened", appending(id, new WalletOpened(id, "Ann"))); + await scenario.WhenActing("the wallet is credited", appending(id, new WalletCredited(id, 5))); + scenario.LastAct.NewEvents.Select(x => x.Data).ShouldBe([new WalletCredited(id, 5)]); + scenario.LastAct.ForeignEvents.ShouldBeEmpty(); + + // A stream arranged by someone else, but addressed by the act, is the specification's too + await scenario.WhenActingOn(addressed, appending(addressed, new WalletCredited(addressed, 2)), + new WalletCredited(addressed, 2)); + scenario.LastAct.NewEvents.Select(x => x.Data).ShouldBe([new WalletCredited(addressed, 2)]); + scenario.LastAct.ForeignEvents.ShouldBeEmpty(); + } + + [Fact] + public async Task an_act_that_starts_a_stream_names_it() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("a wallet is opened", appending(id, new WalletOpened(id, "Gus"))); + await scenario.ThenStreamIsStarted(id); + await scenario.ThenStreamIsStartedWithEvents(new WalletOpened(id, "Gus")); + await scenario.ThenSingleReadModel(Specifications.Specify().With(x => x.Owner, "Gus")); + }); + + recording.GatheredFailures().ShouldBeNull(); + scenario.TheStartedStream().ShouldBe(id); + scenario.LastAct.StartedStreams.ShouldHaveSingleItem().Id.ShouldBe(id); + Should.Throw(() => scenario.TheStartedStreamKey()); + } + + [Fact] + public async Task an_act_that_appends_nothing_and_throws_nothing_is_a_clean_refusal() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Hal")); + + // No act yet: a refusal cannot be claimed + var noAct = await Recordings.RecordAsync(() => + { + scenario.ThenCommandRefused(); + return Task.CompletedTask; + }); + noAct.GatheredFailures().ShouldNotBeNull().ShouldContain("no act has run"); + + var refused = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the debit is refused", _ => Task.CompletedTask); + scenario.ThenCommandRefused(); + scenario.ThenNoEvents(); + }); + refused.GatheredFailures().ShouldBeNull(); + refused.Steps[1].Text.ShouldBe("the command is refused"); + + var appended = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the debit goes through", appending(id, new WalletDebited(id, 1))); + scenario.ThenCommandRefused(); + }); + appended.GatheredFailures().ShouldNotBeNull().ShouldContain("emitted 1 event(s)"); + + var threw = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the debit throws", _ => throw new WalletFrozen(id)); + scenario.ThenCommandRefused(); + }); + threw.GatheredFailures().ShouldNotBeNull().ShouldContain("cleanly refused, but it threw"); + } + + [Fact] + public async Task the_act_succeeding_is_a_step_of_its_own() + { + // bobcat#683: a spec with no event step says nothing about whether the act worked unless it says so + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Ivy")); + + var noAct = await Recordings.RecordAsync(() => + { + scenario.ThenTheActSucceeded(); + return Task.CompletedTask; + }); + noAct.GatheredFailures().ShouldNotBeNull().ShouldContain("no act has run"); + + var succeeded = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("nothing much happens", _ => Task.CompletedTask); + scenario.ThenTheActSucceeded(); + }); + succeeded.GatheredFailures().ShouldBeNull(); + succeeded.Steps[1].Text.ShouldBe("the act succeeds"); + + var threw = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the debit throws", _ => throw new WalletFrozen(id)); + scenario.ThenTheActSucceeded(); + }); + threw.GatheredFailures().ShouldNotBeNull().ShouldContain("The act failed: WalletFrozen"); + threw.Step("the act succeeds").Status.ShouldBe(ResultStatus.failed); + } + + [Fact] + public async Task an_act_that_times_out_fails_its_own_when_step() + { + // bobcat#673: a timed-out act rendered a green When and sent the reader to the Then steps + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Ivy")); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the wallet is credited", + _ => throw new AggregateException(new TimeoutException("This TrackedSession timed out before all activity completed."))); + scenario.ThenEvents(typeof(WalletCredited)); + }); + + var when = recording.Step("the wallet is credited"); + when.Status.ShouldBe(ResultStatus.error); + var failure = when.Failure.ShouldBeOfType(); + failure.Message.ShouldBe("The act timed out: This TrackedSession timed out before all activity completed."); + failure.InnerException.ShouldBeOfType(); + recording.GatheredFailures().ShouldNotBeNull().ShouldContain("When the wallet is credited => The act timed out:"); + + // The outcome still carries what the act threw, for a Then step to judge + scenario.LastAct.Error.ShouldBeOfType(); + } + + [Fact] + public async Task an_act_that_throws_anything_but_a_timeout_leaves_its_when_step_to_the_then_steps() + { + // bobcat#673: a throwing refusal is what ThenRefusedWith asserts, so its When stays green + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Ivy")); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the wallet is credited", _ => throw new WalletFrozen(id)); + scenario.ThenRefusedWith("is frozen"); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Step("the wallet is credited").Status.ShouldBe(ResultStatus.success); + } + + [Fact] + public async Task a_derived_scenario_says_why_its_act_may_have_timed_out() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var scenario = new HintedScenario(store.Host); + + var recording = await Recordings.RecordAsync(() => + scenario.WhenActing("the endpoint is called", _ => throw new TimeoutException("Timed out."))); + + recording.Step("the endpoint is called").Failure.ShouldBeOfType() + .Message.ShouldBe("The act timed out: Timed out. Raise Timeout: the first call compiles."); + } + + private sealed class HintedScenario(IHost host) : CritterStackScenario(host) + { + protected override string? actTimeoutHint(TimeoutException timeout) => "Raise Timeout: the first call compiles."; + } + + [Fact] + public async Task caused_events_are_read_from_everything_the_act_appended() + { + // bobcat#683: ThenCaused reads AllNewEvents, where a tracked scenario puts its cascades' events. This one + // splits a store-only act's outcome the way a tracked one does: nothing of its own, all of it cascaded + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var scenario = new CascadingScenario(store.Host); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Jo")); + await scenario.WhenActing("a cascade credits the wallet", appending(id, new WalletCredited(id, 5))); + scenario.AsCascaded(); + + var recording = await Recordings.RecordAsync(() => + { + scenario.ThenNoEvents(); + scenario.ThenCaused(); + scenario.ThenCaused(Specifications.Specify().With(x => x.Amount, 5m)); + return Task.CompletedTask; + }); + recording.GatheredFailures().ShouldBeNull(); + recording.Steps[1].Text.ShouldBe("WalletCredited is caused"); + + var own = await Recordings.RecordAsync(() => + { + scenario.ThenEmitted(); + return Task.CompletedTask; + }); + own.GatheredFailures().ShouldNotBeNull(); + + var missing = await Recordings.RecordAsync(() => + { + scenario.ThenCaused(); + return Task.CompletedTask; + }); + missing.GatheredFailures().ShouldNotBeNull().ShouldContain("WalletDebited"); + } + + // ---- refusals ---------------------------------------------------------------------------- + + [Fact] + public async Task a_refusal_is_found_inside_the_exceptions_the_act_wrapped_it_in() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Ivy")); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the wallet is credited", + _ => throw new AggregateException(new InvalidOperationException("The credit failed", new WalletFrozen(id)))); + + scenario.ThenValidationFails("is frozen"); + scenario.ThenRefusedWith("is frozen"); + scenario.ThenRefusedWith("is frozen", id); + scenario.ThenRefusedWith(Specifications.Specify().With(x => x.WalletId, id)); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps[1].Text.ShouldBe("validation fails with \"is frozen\""); + recording.Steps[1].Cells.Single(x => x.Name == "reason").Actual.ShouldBe($"The wallet {id} is frozen"); + scenario.LastAct.Error.ShouldBeOfType(); + + // A step that asked about events reports the act failing, not a missing event + var events = await Recordings.RecordAsync(() => + { + scenario.ThenEvents(typeof(WalletCredited)); + return Task.CompletedTask; + }); + events.GatheredFailures().ShouldNotBeNull().ShouldContain("The act failed: AggregateException"); + } + + [Fact] + public async Task a_reason_is_matched_exactly_for_now() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.WhenActing("the wallet is credited", _ => throw new WalletFrozen(id)); + + // bobcat#471 left exact-vs-ignore-case open; exact (Ordinal) until it is decided + var recording = await Recordings.RecordAsync(() => + { + scenario.ThenValidationFails("IS FROZEN"); + return Task.CompletedTask; + }); + + recording.GatheredFailures().ShouldNotBeNull().ShouldContain("Expected a refusal containing \"IS FROZEN\""); + } + + [Fact] + public async Task an_act_that_succeeded_is_not_a_refusal() + { + var scenario = newScenario(); + await scenario.WhenActing("nothing happens", _ => Task.CompletedTask); + + var recording = await Recordings.RecordAsync(() => + { + scenario.ThenValidationFails("anything"); + scenario.ThenRefusedWith(); + return Task.CompletedTask; + }); + + recording.Steps.Select(x => x.Status).ShouldBe([ResultStatus.failed, ResultStatus.failed]); + recording.GatheredFailures().ShouldNotBeNull().ShouldContain("but it succeeded"); + } + + [Fact] + public async Task a_store_only_act_sends_no_messages() + { + var scenario = newScenario(); + await scenario.WhenActing("nothing happens", _ => Task.CompletedTask); + + var recording = await Recordings.RecordAsync(() => + { + scenario.ThenMessagesSent(); + return Task.CompletedTask; + }); + + recording.Steps.Single().Text.ShouldBe("WalletCredited is sent"); + recording.GatheredFailures().ShouldNotBeNull().ShouldContain("but 0 were"); + } + + // ---- a named stream, from a named version (bobcat#461 / wolverine#4938) ------------------- + + [Fact] + public async Task the_stream_gains_only_what_follows_the_named_version() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + + await scenario.GivenEvents(id, new WalletOpened(id, "Ann"), new WalletCredited(id, 10)); + (await scenario.TheStreamVersion(id)).ShouldBe(2); + + // Whatever appends next is not this scenario's act, and does not have to be + await EventStoreAuthoring.AppendAsync(scenario.EventStore, typeof(Wallet), id, [new WalletCredited(id, 25)]); + + await scenario.ThenEventsOn(id, 2, new WalletCredited(id, 25)); + await scenario.ThenEventsOn(id, 2, Specifications.Specify().With(x => x.Amount, 25m)); + await scenario.ThenNoEventsOn(id, 3); + + // Exactly these: a wrong value, a missing event, and an extra one are all wrongs + await Should.ThrowAsync(() => scenario.ThenEventsOn(id, 2, new WalletCredited(id, 99))); + await Should.ThrowAsync(() => scenario.ThenNoEventsOn(id, 2)); + var extra = await Should.ThrowAsync(() => + scenario.ThenEventsOn(id, 1, new WalletCredited(id, 25))); + extra.Message.ShouldContain("after version 1"); + + // A version past the end of the stream would make every check vacuous + var ahead = await Should.ThrowAsync(() => scenario.ThenNoEventsOn(id, 5)); + ahead.Message.ShouldContain("is at version 3"); + } + + [Fact] + public async Task acting_on_a_stream_reads_as_one_paragraph() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(id, new WalletOpened(id, "Bea")); + await scenario.WhenActingOn(id, "the wallet is credited by hand", + appending(id, new WalletCredited(id, 40)), new WalletCredited(id, 40)); + + // The capture feeds the rest of the vocabulary like any other act's + scenario.ThenEvents(new WalletCredited(id, 40)); + + // The undescribed form names the stream instead, and an act that appends nothing passes a check for nothing + await scenario.WhenActingOn(id, _ => Task.CompletedTask); + }); + + recording.GatheredFailures().ShouldBeNull(); + var lines = recording.Lines().ToList(); + // The paragraph's Given follows the arrange's, so the recorder reads it "And" + lines[1].ShouldEndWith(" is at version 1"); + lines[1].ShouldStartWith("And the Wallet stream "); + lines[2].ShouldBe("When the wallet is credited by hand"); + lines[3].ShouldBe("Then WalletCredited is emitted on it"); + lines[4].ShouldBe("And WalletCredited is emitted"); + lines[5].ShouldBe(lines[1].Replace("version 1", "version 2").Replace("And ", "Given ")); + lines[6].ShouldEndWith(" is acted on"); + lines[7].ShouldBe("Then no events are emitted on it"); + + scenario.LastAct.Error.ShouldBeNull(); + } + + [Fact] + public async Task a_wrong_event_fails_only_the_then_sentence() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(id, new WalletOpened(id, "Cy")); + await scenario.WhenActingOn(id, appending(id, new WalletCredited(id, 5)), new WalletDebited(id, 5)); + }); + + recording.Steps.Select(x => x.Status).ShouldBe( + [ResultStatus.success, ResultStatus.success, ResultStatus.success, ResultStatus.failed]); + recording.GatheredFailures().ShouldNotBeNull().ShouldContain("after version 1"); + } + + [Fact] + public async Task a_failing_act_on_a_stream_is_captured_and_reported_by_the_check() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Di")); + + var failed = await Should.ThrowAsync(() => scenario.WhenActingOn(id, + _ => throw new InvalidOperationException("The wallet is frozen"), + new WalletCredited(id, 5))); + + failed.Message.ShouldContain("the act failed"); + failed.Message.ShouldContain("The wallet is frozen"); + + // Captured, as any act is: the throwing-refusal step reads it + scenario.LastAct.Error.ShouldBeOfType(); + scenario.ThenValidationFails("frozen"); + await Should.ThrowAsync(() => scenario.ThenNoEventsOn(id, 1)); + } + + // ---- aggregates, documents and read models ------------------------------------------------ + + [Fact] + public async Task an_async_projection_is_read_once_the_daemon_has_caught_up() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(id, new WalletOpened(id, "Eve")); + await scenario.WhenActing("the wallet is credited", appending(id, new WalletCredited(id, 3))); + + await scenario.ThenReadModel(id, Specifications.Specify().With(x => x.Credits, 1)); + await scenario.ThenSingleDocument(Specifications.Specify().With(x => x.Balance, 3m)); + await scenario.AfterAsyncDaemonsCatchUpAsync(); + await scenario.ThenProjectionsAreCaughtUp(typeof(WalletSummary)); + await scenario.ThenAggregate(id, new Wallet { Id = id, Owner = "Eve", Balance = 3 }); + await scenario.ThenSingleReadModel(); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Lines().ShouldContain("Then the WalletSummary read model has caught up"); + recording.Lines().ShouldContain("Given the async daemons have caught up"); + scenario.ProjectionTimeout.ShouldBe(TimeSpan.FromSeconds(20)); + } + + [Fact] + public async Task a_missing_aggregate_or_document_is_a_failure_with_nothing_to_hand_back() + { + var scenario = newScenario(); + + // Outside a recording a wrong is thrown, so a plain test cannot pass by accident + await Should.ThrowAsync(() => scenario.ThenAggregate(Guid.NewGuid())); + await Should.ThrowAsync(() => scenario.ThenDocument(Guid.NewGuid())); + } + + [Fact] + public async Task a_derived_scenario_names_the_exception_a_wrong_is_thrown_as() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var scenario = new OwnExceptionScenario(store.Host); + await scenario.WhenActing("nothing happens", _ => Task.CompletedTask); + + Should.Throw(() => scenario.ThenEvents(typeof(WalletCredited))); + await Should.ThrowAsync(() => scenario.ThenAggregate(Guid.NewGuid())); + } + + // ---- DCB tags ----------------------------------------------------------------------------- + + [Fact] + public async Task a_dcb_model_is_projected_over_a_tag_query() + { + var scenario = newScenario(); + var screening = new ScreeningId(Guid.NewGuid()); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(screening.Value, + Specifications.Tagged(new ScreeningScheduled(screening, 2), screening), + Specifications.Specify().With(x => x.Screening, screening).With(x => x.Seat, "4C").Tagged(screening)); + + await scenario.ThenDcbModel(EventTagQuery.For(screening), + Specifications.Specify().With(x => x.Seats, 1)); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps.Last().Text.ShouldStartWith("the SeatAvailability over events tagged "); + (await scenario.TheDcbModel(EventTagQuery.For(screening)))!.Seats.ShouldBe(1); + } + + [Fact] + public async Task a_store_only_act_cannot_judge_tags_and_says_why() + { + var scenario = newScenario(); + var screening = new ScreeningId(Guid.NewGuid()); + await scenario.GivenEvents(screening, Specifications.Tagged(new ScreeningScheduled(screening, 2), screening)); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("a seat is reserved", + appending(typeof(SeatAvailability), screening.Value, Specifications.Tagged(new SeatReserved(screening, "4C"), screening))); + + // The stream is named, so no tags are needed + scenario.ThenEventsOn(screening, new SeatReserved(screening, "4C")); + scenario.ThenEvents(Specifications.Specify().With(x => x.Seat, "4C")); + }); + recording.GatheredFailures().ShouldBeNull(); + + // The store appended the events tagged -- the boundary's model sees them -- but reads no tags back + (await scenario.TheDcbModel(EventTagQuery.For(screening)))!.Seats.ShouldBe(1); + scenario.LastAct.EventTagsKnown.ShouldBeFalse(); + + var tags = await Recordings.RecordAsync(() => + { + scenario.ThenEventsOn(EventTagQuery.For(screening), Specifications.Specify()); + scenario.ThenEvents(Specifications.Tagged(new SeatReserved(screening, "4C"), screening)); + return Task.CompletedTask; + }); + + tags.Steps.Select(x => x.Status).ShouldBe([ResultStatus.failed, ResultStatus.failed]); + tags.GatheredFailures().ShouldNotBeNull().ShouldContain("does not return the tags"); + } + + // ---- any object --------------------------------------------------------------------------- + + [Fact] + public async Task any_object_is_verified_as_a_table_or_structurally() + { + var scenario = newScenario(); + var customer = new Customer { Id = Guid.NewGuid(), Name = "Fay", City = "Austin" }; + + var recording = await Recordings.RecordAsync(() => + { + scenario.Verify(customer, StepTable.Parse("| Name | City |\n| Fay | Austin |")); + scenario.ThenMatches(customer, new Customer { Id = customer.Id, Name = "Fay", City = "Austin" }); + scenario.ThenMatches(customer, Specifications.Specify().With(x => x.City, "Austin")); + return Task.CompletedTask; + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps.Select(x => x.Text).ShouldBe(["the Customer has", "the Customer matches", "the Customer matches"]); + + Should.Throw(() => scenario.ThenMatches(customer, Specifications.Specify().With(x => x.City, "Boston"))); + } + + [Fact] + public async Task a_scalar_or_a_collection_is_matched_by_its_value_not_its_clr_type() + { + // bobcat#618: "the Int32 matches ✓ Int32: Int32 ✓ value: 2" and "the String[] matches" + var scenario = new CritterStackScenario(new ServiceCollection().BuildServiceProvider()); + var names = new List { "a", "b" }; + + var recording = await Recordings.RecordAsync(() => + { + scenario.ThenMatches(names.Count, 2); + scenario.ThenMatches(names, new List { "a", "b" }); + return Task.CompletedTask; + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps.Select(x => x.Text).ShouldBe(["the value is 2", "the collection matches [\"a\", \"b\"]"]); + recording.Steps[0].Cells.Select(x => x.Name).ShouldBe(["value"]); + + var wrong = await Recordings.RecordAsync(() => + { + scenario.ThenMatches(names.Count, 3); + return Task.CompletedTask; + }); + wrong.GatheredFailures().ShouldNotBeNull().ShouldContain("Expected 3 but was 2"); + } + + // ---- the clock ---------------------------------------------------------------------------- + + [Fact] + public void clock_steps_render_and_move_the_ambient_clock() + { + var scenario = newScenario(); + BobcatClock.Reset(); + + // Called straight from the test, as a spec calls them: the clock is AsyncLocal, so a value set + // inside an async helper would never flow back out of it + var recording = ScenarioRecorder.Begin("CritterStackScenario", "the clock", null, Guid.NewGuid()); + try + { + scenario.GivenTheDateIs(new DateOnly(2026, 10, 11)); + scenario.WhenTheClockAdvancesBy(TimeSpan.FromDays(3)); + scenario.WhenTimePasses(TimeSpan.FromHours(1.5)); + scenario.Clock.GetUtcNow().ShouldBe(new DateTimeOffset(2026, 10, 14, 1, 30, 0, TimeSpan.Zero)); + + scenario.GivenTheTimeIs(new DateTimeOffset(2026, 1, 2, 9, 15, 0, TimeSpan.Zero)); + scenario.WhenTheClockAdvancesTo(new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero)); + BobcatClock.Current.GetUtcNow().ShouldBe(new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero)); + } + finally + { + recording.Dispose(); + BobcatClock.Reset(); + } + + recording.Lines().ShouldBe([ + "Given the date is 2026-10-11", + "When the clock advances by 3 days", + "And 1 hour 30 minutes passes", + "Given the current time is 2026-01-02 09:15", + "When the clock advances to 2026-01-03" + ]); + } + + // ---- run evidence (bobcat#465) ------------------------------------------------------------ + + [Fact] + public async Task what_the_scenario_touched_travels_on_scenario_finished() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + var sink = new RecordingSink(); + + await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(id, new WalletOpened(id, "Gil")); + await scenario.WhenActing("the wallet is credited", appending(id, new WalletCredited(id, 2))); + + // Expected, never appended: observed-never-asserted keeps it out + scenario.ThenNotEmitted(); + await scenario.ThenReadModel(id); + }, sink); + + var finished = sink.Events.OfType().ShouldHaveSingleItem(); + finished.TouchedTypes.ShouldNotBeNull().Select(x => x.Name) + .ShouldBe(["Wallet", "WalletOpened", "WalletCredited", "WalletSummary"]); + finished.TouchedTypes![0].FullName.ShouldBe(typeof(Wallet).FullName); + finished.TouchedTypes![0].AssemblyName.ShouldBe(typeof(Wallet).Assembly.GetName().Name); + } + + // ---- review findings on bobcat#474 ------------------------------------------------------- + + [Fact] + public async Task a_subclass_of_the_expected_exception_is_a_match() + { + var scenario = newScenario(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("the wallet is credited", _ => throw new ArgumentNullException("amount")); + scenario.ThenRefusedWith().ShouldBeOfType(); + }); + + recording.GatheredFailures().ShouldBeNull(); + var cell = recording.Step("refused with ArgumentException").Cells.Single(x => x.Name == "exception"); + cell.Status.ShouldBe(ResultStatus.success); + cell.Actual.ShouldBe(nameof(ArgumentNullException)); + } + + [Fact] + public async Task a_step_that_throws_fails_on_its_own_line() + { + var scenario = newScenario(); + + // A read model that cannot be stored is a SpecCriticalException thrown while its Given is open + var recording = await Recordings.RecordAsync(async () => + { + await Should.ThrowAsync(() => scenario.GivenReadModel(new Wallet())); + }); + + recording.Steps.Single().Status.ShouldNotBe(ResultStatus.success); + } + + [Fact] + public async Task a_store_that_throws_inside_a_step_fails_that_step() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + + // An application with no event store at all: the act runs, every store read throws + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + var scenario = new CritterStackScenario( + Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services)); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("nothing happens", _ => Task.CompletedTask); + await Should.ThrowAsync(() => scenario.ThenStreamIsStarted(id)); + await Should.ThrowAsync(() => scenario.AfterAsyncDaemonsCatchUpAsync()); + await Should.ThrowAsync(() => scenario.ThenProjectionsAreCaughtUp(typeof(WalletSummary))); + }); + + recording.Steps.Select(x => x.Status == ResultStatus.success).ShouldBe([true, false, false, false]); + } + + [Fact] + public async Task a_missing_aggregate_or_read_model_fails_a_rendered_step() + { + var scenario = newScenario(); + + var recording = await Recordings.RecordAsync(async () => + { + await Should.ThrowAsync(() => scenario.ThenAggregate(Guid.NewGuid())); + await Should.ThrowAsync(() => scenario.ThenReadModel(Guid.NewGuid())); + }); + + var steps = recording.Steps.ToList(); + steps.Select(x => x.Status).ShouldBe([ResultStatus.failed, ResultStatus.success, ResultStatus.failed]); + steps[0].Text.ShouldStartWith("there is a Wallet "); + steps[1].Text.ShouldBe("the Customer read model has caught up"); + steps[2].Text.ShouldStartWith("there is a Customer "); + } + + [Fact] + public async Task a_step_about_the_act_fails_when_no_act_has_run() + { + var scenario = newScenario(); + var screening = new ScreeningId(Guid.NewGuid()); + + var recording = await Recordings.RecordAsync(() => + { + scenario.ThenNoEvents(); + scenario.ThenNotEmitted(); + scenario.ThenNoEventsOn(Guid.NewGuid()); + scenario.ThenNoEventsOn(EventTagQuery.For(screening)); + return Task.CompletedTask; + }); + + recording.Steps.Select(x => x.Status).ShouldAllBe(x => x == ResultStatus.failed); + recording.Steps.Count.ShouldBe(4); + recording.GatheredFailures().ShouldNotBeNull().ShouldContain("no act has run"); + Should.Throw(() => scenario.ThenNoEvents()).Message.ShouldContain("no act has run"); + + // The refusal half of a guard: an act that threw and appended nothing still passes ThenNoEvents + await scenario.WhenActing("the debit is refused", _ => throw new WalletFrozen(Guid.NewGuid())); + scenario.ThenNoEvents(); + } + + [Fact] + public async Task a_started_stream_answers_only_to_its_own_aggregate_type() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + var screening = new ScreeningId(id); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("a screening is scheduled", + appending(typeof(SeatAvailability), id, new ScreeningScheduled(screening, 10))); + await scenario.ThenStreamIsStartedWithEvents(new ScreeningScheduled(screening, 10)); + }); + + recording.GatheredFailures().ShouldNotBeNull().ShouldContain("started no Wallet stream"); + scenario.LastAct.StartedStreams.ShouldHaveSingleItem().AggregateType.ShouldBe(typeof(SeatAvailability)); + scenario.TheStartedStream().ShouldBe(id); + Should.Throw(() => scenario.TheStartedStream()).Message.ShouldContain("It started: SeatAvailability"); + + // A stream started with no aggregate type still answers to any + var untyped = Guid.NewGuid(); + await scenario.WhenActing("an untyped stream is started", + services => EventStoreAuthoring.AppendAsync(services.EventStore(), null!, untyped, [new WalletOpened(untyped, "Jo")])); + scenario.LastAct.StartedStreams.ShouldHaveSingleItem().AggregateType.ShouldBeNull(); + scenario.TheStartedStream().ShouldBe(untyped); + } + + [Fact] + public async Task a_step_that_could_never_fail_is_refused_up_front() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.WhenActing("the wallet is credited", _ => throw new WalletFrozen(id)); + + Should.Throw(() => scenario.ThenMessagesSent(0)); + Should.Throw(() => scenario.ThenMessagesSent(-1)); + Should.Throw(() => scenario.ThenRefusedWith("")); + Should.Throw(() => scenario.ThenRefusedWith(" ", id)); + Should.Throw(() => scenario.ThenValidationFails(" ")); + } + + [Fact] + public void the_clock_steps_render_what_they_did_in_words_the_grammar_reads_back() + { + var scenario = newScenario(); + BobcatClock.Reset(); + var at = new DateTimeOffset(2026, 1, 2, 9, 15, 30, 123, TimeSpan.Zero).AddTicks(4567); + + var recording = ScenarioRecorder.Begin("CritterStackScenario", "the clock, to the tick", null, Guid.NewGuid()); + try + { + scenario.GivenTheTimeIs(at); + scenario.WhenTimePasses(TimeSpan.FromTicks(5_000)); + scenario.WhenTheClockAdvancesBy(TimeSpan.FromTicks(15_002_500)); + scenario.WhenTheClockAdvancesTo(at.AddHours(1)); + + // A clock that goes backwards is not one that advances or passes + Should.Throw(() => scenario.WhenTimePasses(TimeSpan.FromHours(-1))); + Should.Throw(() => scenario.WhenTheClockAdvancesBy(TimeSpan.FromTicks(-1))); + } + finally + { + recording.Dispose(); + BobcatClock.Reset(); + } + + recording.Lines().ShouldBe([ + "Given the current time is 2026-01-02 09:15:30.1234567", + "When 0.5 milliseconds passes", + "And the clock advances by 1 second 500.25 milliseconds", + "And the clock advances to 2026-01-02 10:15:30.1234567" + ]); + + // Each duration reads back through the friendly time-span parser + FriendlyTimeSpanParser.TryParse("0.5 milliseconds", out var half).ShouldBeTrue(); + half.ShouldBe(TimeSpan.FromTicks(5_000)); + FriendlyTimeSpanParser.TryParse("1 second 500.25 milliseconds", out var longer).ShouldBeTrue(); + longer.ShouldBe(TimeSpan.FromTicks(15_002_500)); + } + + [Fact] + public async Task the_high_water_sequence_is_the_highest_sequence_in_the_store() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.GivenEvents(id, new WalletOpened(id, "Kai"), new WalletCredited(id, 1)); + + var all = await EventStores.QueryEventsSinceAsync(scenario.EventStore, 0); + (await EventStores.HighWaterSequenceAsync(scenario.EventStore)).ShouldBe(all.Max(x => x.Sequence)); + } + + [Fact] + public async Task a_scenario_can_be_built_from_the_services_alone() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var scenario = new CritterStackScenario(store.Host.Services); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(id, new WalletOpened(id, "Lu")); + await scenario.WhenActing("the wallet is credited", appending(id, new WalletCredited(id, 8))); + scenario.ThenEvents(new WalletCredited(id, 8)); + await scenario.ThenAggregate(id, Specifications.Specify().With(x => x.Balance, 8m)); + }); + + recording.GatheredFailures().ShouldBeNull(); + scenario.Services.ShouldBeSameAs(store.Host.Services); + Should.Throw(() => scenario.Host).Message.ShouldContain("IServiceProvider"); + new CritterStackScenario(store.Host).Services.ShouldBeSameAs(store.Host.Services); + } + + private sealed class OwnSpecificationFailure(string message) : Exception(message); + + private sealed class CascadingScenario(Microsoft.Extensions.Hosting.IHost host) : CritterStackScenario(host) + { + public void AsCascaded() => LastAct = LastAct with { NewEvents = [] }; + } + + private sealed class OwnExceptionScenario(Microsoft.Extensions.Hosting.IHost host) : CritterStackScenario(host) + { + protected override Exception failureOf(string message) => new OwnSpecificationFailure(message); + } +} + +public class critter_stack_scenario_on_marten(MartenStoreHost store) + : critter_stack_scenario(store), IClassFixture; + +public class critter_stack_scenario_on_fisher(FisherStoreHost store) + : critter_stack_scenario(store), IClassFixture; + +/// +/// The reset wipes the whole store, so it runs on a host of its own rather than under the other tests' +/// async daemon. +/// +public abstract class resetting_a_scenario(THost store) where THost : StoreHost +{ + private static Func appending(Guid id, params object[] events) + => services => EventStoreAuthoring.AppendAsync(services.EventStore(), typeof(Wallet), id, events); + + private CritterStackScenario newScenario() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + return new CritterStackScenario(store.Host); + } + + [Fact] + public async Task reset_forgets_the_last_act() + { + var scenario = newScenario(); + var id = Guid.NewGuid(); + await scenario.WhenActing("a wallet is opened", appending(id, new WalletOpened(id, "Hu"))); + scenario.LastAct.NewEvents.ShouldNotBeEmpty(); + + await scenario.ResetAsync(); + + scenario.LastAct.ShouldBeSameAs(CritterStackActOutcome.None); + (await scenario.TheStreamVersion(id)).ShouldBe(0); + } + + // bobcat#680: truncating under the running async daemon left its progression ahead of the emptied store, and + // the daemon logged a ProgressionProgressOutOfOrderException per reset. The specs still passed, so the log is + // the evidence; and the paused daemon must come back, or the projection after the reset never catches up. + [Fact] + public async Task reset_pauses_the_async_daemon_and_resumes_it() + { + var scenario = newScenario(); + + for (var round = 0; round < 3; round++) + { + var id = Guid.NewGuid(); + await scenario.WhenActing("a wallet is opened and credited", + appending(id, new WalletOpened(id, "Hu"), new WalletCredited(id, 5m))); + await scenario.ThenReadModel(id, Specifications.Specify().With(x => x.Credits, 1)); + + await scenario.ResetAsync(); + } + + store.Errors.Entries.ShouldNotContain(e => e.Contains("out of order", StringComparison.OrdinalIgnoreCase)); + } + +} + +/// +/// bobcat#497. A read of a projected document is a spec verdict even on a store that holds no events at all — the +/// first test to run against a fresh schema. Marten's own non-stale wait throws an immediate TimeoutException on a +/// store with no events, which made a_missing_aggregate_or_document_is_a_failure_with_nothing_to_hand_back +/// fail whenever xUnit happened to run it first in its class. A class of its own, so its host's store is empty. +/// +public abstract class reading_an_empty_store(THost store) where THost : StoreHost +{ + [Fact] + public async Task a_missing_document_on_a_store_with_no_events_is_a_spec_failure_not_a_timeout() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var scenario = new CritterStackScenario(store.Host); + (await EventStores.HighWaterSequenceAsync(scenario.EventStore)).ShouldBe(0, "this class's store must start empty"); + + await Should.ThrowAsync(() => scenario.ThenDocument(Guid.NewGuid())); + await scenario.ThenNoReadModel(); + await scenario.ThenProjectionsAreCaughtUp(typeof(WalletSummary)); + } +} + +/// +/// bobcat#620: a document read on a store with no async projection renders no catch-up step — there is nothing to +/// catch up, and in a document-only app it doubled every Then line. With one, the step stays. +/// +public abstract class reading_a_store_with_no_async_projections(THost store) where THost : StoreHost +{ + [Fact] + public async Task renders_no_catch_up_step_before_a_read() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var scenario = new CritterStackScenario(store.Host); + var id = Guid.NewGuid(); + var wallet = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenReadModel(new Customer { Id = id, Name = "Ann", City = "Austin" }); + await scenario.GivenEvents(wallet, new WalletOpened(wallet, "Eve")); + await scenario.ThenDocument(id, Specifications.Specify().With(x => x.City, "Austin")); + await scenario.ThenNoReadModel(Guid.NewGuid()); + await scenario.ThenNoReadModel(); + await scenario.ThenReadModel(wallet); + await scenario.ThenProjectionsAreCaughtUp(typeof(WalletSummary)); + }); + + recording.GatheredFailures().ShouldBeNull(); + var lines = recording.Lines().ToList(); + lines.ShouldNotContain(x => x.Contains("Customer read model has caught up")); + lines.ShouldNotContain(x => x.Contains("Ghost read model has caught up")); + // Only the step the author wrote renders: it is theirs + lines.Count(x => x.Contains("read model has caught up")).ShouldBe(1); + recording.Steps.Last().Text.ShouldBe("the WalletSummary read model has caught up"); + } +} + +public class reading_a_store_with_no_async_projections_on_marten(InlineOnlyMartenStoreHost store) + : reading_a_store_with_no_async_projections(store), IClassFixture; + +public class reading_a_store_with_no_async_projections_on_fisher(InlineOnlyFisherStoreHost store) + : reading_a_store_with_no_async_projections(store), IClassFixture; + +public class reading_an_empty_store_on_marten(MartenStoreHost store) + : reading_an_empty_store(store), IClassFixture; + +public class reading_an_empty_store_on_fisher(FisherStoreHost store) + : reading_an_empty_store(store), IClassFixture; + +/// +/// bobcat#497, decided as C: a catch-up wait that times out before a read stays an error, but says which wait, which +/// projections were behind, the timeout and what was being read — the store's own exception kept inside. +/// +public class a_catch_up_timeout_before_a_read +{ + [Fact] + public async Task is_an_error_that_names_the_wait_the_lagging_projection_the_timeout_and_the_identity() + { + var storeTimeout = new TimeoutException("No event activity was detected within the timeout span"); + var database = NSubstitute.Substitute.For(); + NSubstitute.SubstituteExtensions.Returns(database.WaitForNonStaleProjectionDataAsync(NSubstitute.Arg.Any()), + Task.FromException(storeTimeout)); + NSubstitute.SubstituteExtensions.Returns(database.FetchHighestEventSequenceNumber(NSubstitute.Arg.Any()), + Task.FromResult(12L)); + NSubstitute.SubstituteExtensions.Returns(database.AllProjectionProgress(NSubstitute.Arg.Any()), + Task.FromResult>( + [ + new(JasperFx.Events.Projections.ShardState.HighWaterMark, 12), + new("WalletSummary:All", 7), + new("Ledger:All", 12) + ])); + + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton( + services, new FakeEventStore { Databases = [database] }); + var scenario = new CritterStackScenario( + Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services)) + { + ProjectionTimeout = TimeSpan.FromSeconds(3) + }; + var id = Guid.NewGuid(); + + // An error, not a spec failure: never a SpecAssertionException, gathered or thrown + var ex = await Should.ThrowAsync(() => scenario.ThenDocument(id)); + + ex.InnerException.ShouldBeSameAs(storeTimeout); + ex.ShouldBeAssignableTo(); + ex.Wait.ShouldBe("the Customer read model has caught up"); + ex.Behind.ShouldBe(["'WalletSummary:All' at 7 of 12"]); + ex.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + ex.ReadType.ShouldBe(typeof(Customer)); + ex.Identity.ShouldBe(id); + ex.Message.ShouldContain("'the Customer read model has caught up' timed out after 3s"); + ex.Message.ShouldContain($"the Customer with id {id}"); + ex.Message.ShouldContain("Behind: 'WalletSummary:All' at 7 of 12."); + ex.Message.ShouldContain("No event activity was detected"); + + // Inside a recording the step that waited fails on its own line, with the same exception + var recording = await Recordings.RecordAsync(async () => + await Should.ThrowAsync(() => scenario.ThenReadModel(id))); + // ...at the error level: the environment, not a wrong + recording.Steps.Single().Text.ShouldBe("the Customer read model has caught up"); + recording.Steps.Single().Status.ShouldBe(ResultStatus.error); + } + + // bobcat#686: a store that cannot say which projection was behind says why, rather than the reason being discarded + [Fact] + public async Task says_why_the_store_could_not_say_which_projection_was_behind() + { + var storeTimeout = new TimeoutException("No event activity was detected within the timeout span"); + var dropped = new InvalidOperationException("42P01: relation \"daemon.mt_event_progression\" does not exist"); + var database = NSubstitute.Substitute.For(); + NSubstitute.SubstituteExtensions.Returns(database.WaitForNonStaleProjectionDataAsync(NSubstitute.Arg.Any()), + Task.FromException(storeTimeout)); + NSubstitute.SubstituteExtensions.Returns(database.FetchHighestEventSequenceNumber(NSubstitute.Arg.Any()), + Task.FromResult(12L)); + NSubstitute.SubstituteExtensions.Returns(database.AllProjectionProgress(NSubstitute.Arg.Any()), + Task.FromException>(dropped)); + + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton( + services, new FakeEventStore { Databases = [database] }); + var scenario = new CritterStackScenario( + Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services)) + { + ProjectionTimeout = TimeSpan.FromSeconds(3) + }; + + var ex = await Should.ThrowAsync(() => scenario.ThenDocument(Guid.NewGuid())); + + ex.Behind.ShouldBeEmpty(); + ex.BehindFailure.ShouldBeSameAs(dropped); + ex.InnerException.ShouldBeSameAs(storeTimeout); + ex.Message.ShouldContain("The store could not say which projection was behind " + + "(InvalidOperationException: 42P01: relation \"daemon.mt_event_progression\" does not exist)."); + ex.Message.ShouldContain("No event activity was detected"); + } +} + +/// +/// bobcat#685: the #497 diagnosis outside a read — , +/// which reads nothing, and for a suite's own wait. +/// +public class a_catch_up_timeout_with_no_read +{ + private static readonly TimeoutException storeTimeout = new("No event activity was detected within the timeout span"); + + private static FakeEventStore lagging() + { + var database = NSubstitute.Substitute.For(); + NSubstitute.SubstituteExtensions.Returns(database.WaitForNonStaleProjectionDataAsync(NSubstitute.Arg.Any()), + Task.FromException(storeTimeout)); + NSubstitute.SubstituteExtensions.Returns(database.FetchHighestEventSequenceNumber(NSubstitute.Arg.Any()), + Task.FromResult(12L)); + NSubstitute.SubstituteExtensions.Returns(database.AllProjectionProgress(NSubstitute.Arg.Any()), + Task.FromResult>( + [ + new(JasperFx.Events.Projections.ShardState.HighWaterMark, 12), + new("WalletSummary:All", 7) + ])); + return new FakeEventStore { Databases = [database] }; + } + + private static CritterStackScenario scenarioOver(FakeEventStore store) + { + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton( + services, store); + return new CritterStackScenario( + Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services)) + { + ProjectionTimeout = TimeSpan.FromSeconds(3) + }; + } + + [Fact] + public async Task after_async_daemons_catch_up_names_the_lagging_projection_and_reads_nothing() + { + var scenario = scenarioOver(lagging()); + + var ex = await Should.ThrowAsync(() => scenario.AfterAsyncDaemonsCatchUpAsync()); + + ex.InnerException.ShouldBeSameAs(storeTimeout); + ex.Wait.ShouldBe("the async daemons have caught up"); + ex.Behind.ShouldBe(["'WalletSummary:All' at 7 of 12"]); + ex.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + ex.ReadType.ShouldBeNull(); + ex.Identity.ShouldBeNull(); + ex.Message.ShouldBe( + "Waiting for 'the async daemons have caught up' timed out after 3s. Behind: 'WalletSummary:All' at 7 of 12. " + + "This is the environment (a daemon behind, stopped or not running), not evidence about any read model. " + + "The store said: TimeoutException: No event activity was detected within the timeout span"); + + // Inside a recording the step errors on its own line + var recording = await Recordings.RecordAsync(async () => + await Should.ThrowAsync(() => scenario.AfterAsyncDaemonsCatchUpAsync())); + recording.Steps.Single().Text.ShouldBe("the async daemons have caught up"); + recording.Steps.Single().Status.ShouldBe(ResultStatus.error); + } + + [Fact] + public async Task after_async_daemons_catch_up_for_a_tenant_is_diagnosed_too() + { + var scenario = scenarioOver(lagging()); + + var ex = await Should.ThrowAsync(() => scenario.AfterAsyncDaemonsCatchUpAsync("red")); + + ex.Wait.ShouldBe("the async daemons have caught up for tenant \"red\""); + ex.Behind.ShouldBe(["'WalletSummary:All' at 7 of 12"]); + ex.ReadType.ShouldBeNull(); + } + + [Fact] + public async Task a_suites_own_wait_gets_the_same_diagnosis() + { + var store = lagging(); + + var ex = await EventStores.DiagnoseCatchUpTimeoutAsync(store, "the daemon has caught up", TimeSpan.FromSeconds(2), + storeTimeout, token: TestContext.Current.CancellationToken); + + ex.InnerException.ShouldBeSameAs(storeTimeout); + ex.Wait.ShouldBe("the daemon has caught up"); + ex.Behind.ShouldBe(["'WalletSummary:All' at 7 of 12"]); + ex.Timeout.ShouldBe(TimeSpan.FromSeconds(2)); + ex.ReadType.ShouldBeNull(); + ex.Message.ShouldStartWith("Waiting for 'the daemon has caught up' timed out after 2s. Behind:"); + + var reading = await EventStores.DiagnoseCatchUpTimeoutAsync(store, "the daemon has caught up", TimeSpan.FromSeconds(2), + storeTimeout, typeof(Customer), 42, TestContext.Current.CancellationToken); + reading.ReadType.ShouldBe(typeof(Customer)); + reading.Message.ShouldContain("timed out after 2s, before reading the Customer with id 42."); + reading.Message.ShouldContain("not evidence about the Customer itself."); + } + + [Fact] + public async Task an_already_diagnosed_timeout_is_returned_as_it_is() + { + var store = lagging(); + var first = await EventStores.DiagnoseCatchUpTimeoutAsync(store, "inner wait", TimeSpan.FromSeconds(1), storeTimeout, token: TestContext.Current.CancellationToken); + + var second = await EventStores.DiagnoseCatchUpTimeoutAsync(store, "outer wait", TimeSpan.FromSeconds(5), first, token: TestContext.Current.CancellationToken); + + second.ShouldBeSameAs(first); + } +} + +public class resetting_a_scenario_on_marten(MartenStoreHost store) + : resetting_a_scenario(store), IClassFixture; + +public class resetting_a_scenario_on_fisher(FisherStoreHost store) + : resetting_a_scenario(store), IClassFixture; + +/// +/// bobcat#616: in a document app the act's document gets its id from inside the endpoint or a cascaded handler, so +/// there is no stream and no id to name. ThenSingleDocument / ThenSingleReadModel read the one such +/// document in the store. A class of its own, so its host's store holds only what these tests put there. +/// +public abstract class reading_a_document_the_system_named(THost store) where THost : StoreHost +{ + private static Func storing(params Customer[] customers) + => services => DocumentStores.StoreAllAsync(services.EventStore(), typeof(Customer), customers); + + [Fact] + public async Task exactly_one_is_the_subject_and_none_or_several_is_a_wrong_naming_the_count() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var scenario = new CritterStackScenario(store.Host); + + // A wrong, never an InvalidOperationException from Single(): the step fails as an assertion, and with + // nothing to hand back it ends the test, as a missing ThenReadModel document does + var none = await Should.ThrowAsync(() => scenario.ThenSingleDocument()); + none.Message.ShouldContain("Expected exactly one Customer document, but there is none"); + + Customer? read = null; + var one = await Recordings.RecordAsync(async () => + { + await scenario.WhenActing("a customer signs up", storing(new Customer { Id = Guid.NewGuid(), Name = "Ann" })); + read = await scenario.ThenSingleDocument(Specifications.Specify().With(x => x.Name, "Ann")); + await scenario.ThenSingleReadModel(Specifications.Specify().With(x => x.Name, "Ann")); + }); + + one.GatheredFailures().ShouldBeNull(); + read.ShouldNotBeNull().Name.ShouldBe("Ann"); + + var wrong = await Recordings.RecordAsync(() => + scenario.ThenSingleDocument(Specifications.Specify().With(x => x.Name, "Bo"))); + wrong.GatheredFailures().ShouldNotBeNull(); + + await scenario.WhenActing("another customer signs up", storing(new Customer { Id = Guid.NewGuid(), Name = "Cy" })); + + SpecAssertionException? several = null; + var recording = await Recordings.RecordAsync(async () => + { + several = await Should.ThrowAsync(() => scenario.ThenSingleReadModel()); + }); + + several!.Message.ShouldContain("Expected exactly one Customer document, but there are 2"); + recording.Step("there is exactly one Customer").Status.ShouldBe(ResultStatus.failed); + } + +} + +public class reading_a_document_the_system_named_on_marten(MartenStoreHost store) + : reading_a_document_the_system_named(store), IClassFixture; + +public class reading_a_document_the_system_named_on_fisher(FisherStoreHost store) + : reading_a_document_the_system_named(store), IClassFixture; + +/// +/// bobcat#683: several documents under ids the act chose, read with TheDocuments and judged with +/// ThenDocuments. A class of its own, so its host's store holds only what this test puts there. +/// +public abstract class reading_several_documents_the_system_named(THost store) where THost : StoreHost +{ + private static Func storing(params Customer[] customers) + => services => DocumentStores.StoreAllAsync(services.EventStore(), typeof(Customer), customers); + + [Fact] + public async Task several_documents_the_system_named_are_read_and_judged_as_a_set() + { + // More than one document under ids the act chose + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var scenario = new CritterStackScenario(store.Host); + var marker = Guid.NewGuid().ToString("N"); + + await scenario.WhenActing("two customers sign up", storing( + new Customer { Id = Guid.NewGuid(), Name = "Lu " + marker }, + new Customer { Id = Guid.NewGuid(), Name = "Mo " + marker })); + + (await scenario.TheDocuments()).Select(x => x.Name).OrderBy(x => x).ShouldBe(["Lu " + marker, "Mo " + marker]); + + var expected = new object[] + { + Specifications.Specify().With(c => c.Name, "Mo " + marker), + Specifications.Specify().With(c => c.Name, "Lu " + marker) + }; + + var recording = await Recordings.RecordAsync(() => scenario.ThenDocuments(expected)); + recording.GatheredFailures().ShouldBeNull(); + recording.Steps.ShouldContain(x => x.Text.StartsWith("the Customer documents are ")); + + var wrong = await Recordings.RecordAsync(() => scenario.ThenDocuments( + Specifications.Specify().With(c => c.Name, "Nobody " + marker))); + wrong.GatheredFailures().ShouldNotBeNull().ShouldContain("Nobody " + marker); + } +} + +public class reading_several_documents_the_system_named_on_marten(MartenStoreHost store) + : reading_several_documents_the_system_named(store), IClassFixture; + +public class reading_several_documents_the_system_named_on_fisher(FisherStoreHost store) + : reading_several_documents_the_system_named(store), IClassFixture; diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_spec.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_spec.cs new file mode 100644 index 000000000..90804aab8 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_spec.cs @@ -0,0 +1,61 @@ +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +/// +/// is the base class a Marten- or Fisher-only suite derives from: the whole +/// vocabulary as its own members, delegated to one . +/// +public abstract class a_spec_class(THost store) : CritterStackSpec(store.IsAvailable ? store.Host : new NoHost()) + where THost : StoreHost +{ + [Fact] + public async Task a_spec_class_reads_given_when_then() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var id = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await GivenEvents(id, new WalletOpened(id, "Ivo")); + await WhenActing("the wallet is credited", + services => EventStoreAuthoring.AppendAsync(services.EventStore(), typeof(Wallet), id, [new WalletCredited(id, 4)])); + ThenEvents(Specify().With(x => x.Amount, 4m)); + await ThenAggregate(id, Specify().With(x => x.Balance, 4m)); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Steps.Select(x => x.Keyword).ShouldBe(["Given", "When", "Then", "And"]); + LastAct.NewEvents.ShouldHaveSingleItem(); + ProjectionTimeout.ShouldBe(TimeSpan.FromSeconds(20)); + } + + [Fact] + public async Task a_spec_class_addresses_a_store_by_name() + { + Assert.SkipUnless(store.IsAvailable, store.SkipReason); + var id = Guid.NewGuid(); + + // bobcat#504: the view shares the spec's specification, so the act sees the stream it arranged + await In("main").GivenEvents(id, new WalletOpened(id, "Jo")); + await WhenActing("the wallet is credited", + services => EventStoreAuthoring.AppendAsync(services.EventStore(), typeof(Wallet), id, [new WalletCredited(id, 2)])); + + In("main").EventStore.ShouldBeSameAs(EventStore); + LastAct.NewEvents.ShouldHaveSingleItem(); + LastAct.ForeignEvents.ShouldBeEmpty(); + } + + // Stands in for a host when the store is not available here, so the class constructs and the test skips + private sealed class NoHost : Microsoft.Extensions.Hosting.IHost + { + public IServiceProvider Services { get; } = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(new Microsoft.Extensions.DependencyInjection.ServiceCollection()); + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Dispose() { } + } +} + +public class a_spec_class_on_marten(MartenStoreHost store) : a_spec_class(store), IClassFixture; + +public class a_spec_class_on_fisher(FisherStoreHost store) : a_spec_class(store), IClassFixture; diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/default_main_store.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/default_main_store.cs new file mode 100644 index 000000000..e080d857d --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/default_main_store.cs @@ -0,0 +1,256 @@ +using Bobcat.Engine; +using JasperFx.Descriptors; +using JasperFx.Events; +using JasperFx.Events.Daemon; +using JasperFx.Events.Descriptors; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +// The main store is the default: a host with one main event store and any number of ancillary ones (Marten's +// AddMartenStore, Fisher's AddFisherStore, an embedded CritterWatch console's own store) needs no storeName. +// Against fake stores registered the way Marten, Polecat and Fisher register theirs — the main store under the +// product's IDocumentStore, an ancillary store under a marker interface — with no database. The same rules run +// against real Marten and Fisher stores in ancillary_stores. + +public class default_main_store +{ + private sealed class Probe(IServiceProvider services, string? storeName = null) : CritterStackScenario(services, storeName) + { + public Task Route(Type? model, params object[] values) => storeForAsync("the step", model, values); + } + + /// A fake store with its product's IDocumentStore interface, describing what it owns. + private sealed class ProductStore(string name) : FakeEventStore(name), FakeProduct.IDocumentStore, IFakeAncillaryStore + { + // The reset convention Marten, Polecat and Fisher share, so ResetAsync runs on a fake + public ResettableAdvanced Advanced { get; } = new(); + + public static ProductStore Owning(string name, Type? aggregate = null) + { + var store = new ProductStore(name) { Usage = new EventStoreUsage() }; + if (aggregate is not null) + { + store.Usage.Subscriptions.Add(new SubscriptionDescriptor(SubscriptionType.SingleStreamProjection) + { + AggregateType = TypeDescriptor.For(aggregate) + }); + } + + return store; + } + } + + public sealed class ResettableAdvanced + { + public Task ResetAllDataAsync(CancellationToken token) => Task.CompletedTask; + } + + /// A store of a second product, as when a host runs both Marten and Fisher. + private sealed class OtherProductStore(string name) : FakeEventStore(name), OtherProduct.IDocumentStore; + + private static ServiceCollection services() => new(); + + // AddMarten / AddPolecat / AddFisher: the store under the product's IDocumentStore, and as IEventStore + private static ServiceCollection main(ServiceCollection services, ProductStore store) + { + services.AddSingleton(store); + services.AddSingleton(store); + return services; + } + + // AddMartenStore and friends: the store under its marker only, and as IEventStore + private static ServiceCollection ancillary(ServiceCollection services, ProductStore store) + { + services.AddSingleton(store); + services.AddSingleton(store); + return services; + } + + [Fact] + public async Task one_main_store_beside_an_ancillary_one_is_the_default() + { + var app = ProductStore.Owning("main", aggregate: typeof(Wallet)); + var console = ProductStore.Owning("icritterwatchstore", aggregate: typeof(Invoice)); + var provider = ancillary(main(services(), app), console).BuildServiceProvider(); + + provider.MainEventStores().ShouldBe([app]); + provider.IsMainEventStore(console).ShouldBeFalse(); + provider.EventStore().ShouldBeSameAs(app); + + var scenario = new Probe(provider); + scenario.EventStore.ShouldBeSameAs(app); + + // Unclaimed and untyped steps fall back to the main store; a type the ancillary store claims still goes there + (await scenario.Route(typeof(Unclaimed))).ShouldBeSameAs(app); + (await scenario.Route(null)).ShouldBeSameAs(app); + (await scenario.Route(typeof(Wallet))).ShouldBeSameAs(app); + (await scenario.Route(typeof(Invoice))).ShouldBeSameAs(console); + + // Explicit addressing is untouched + provider.EventStore("icritterwatchstore").ShouldBeSameAs(console); + new Probe(provider, "icritterwatchstore").EventStore.ShouldBeSameAs(console); + scenario.In("icritterwatchstore").EventStore.ShouldBeSameAs(console); + scenario.In().EventStore.ShouldBeSameAs(console); + } + + [Fact] + public async Task several_stores_claiming_a_type_fall_back_to_the_main_store_among_them() + { + var app = ProductStore.Owning("main", aggregate: typeof(SharedNote)); + var console = ProductStore.Owning("icritterwatchstore", aggregate: typeof(SharedNote)); + var scenario = new Probe(ancillary(main(services(), app), console).BuildServiceProvider()); + + (await scenario.Route(typeof(SharedNote))).ShouldBeSameAs(app); + } + + [Fact] + public async Task two_main_stores_keep_the_refusal() + { + var marten = ProductStore.Owning("main"); + var fisher = new OtherProductStore("main"); + var services = main(default_main_store.services(), marten); + services.AddSingleton(fisher); + services.AddSingleton(fisher); + var provider = services.BuildServiceProvider(); + + provider.MainEventStores().Count.ShouldBe(2); + + var ex = Should.Throw(() => provider.EventStore()); + ex.Message.ShouldContain("2 of them are registered as a main store"); + ex.Message.ShouldContain("Pass storeName"); + ex.Message.ShouldContain("In()"); + ex.Message.ShouldContain("In(\"name\")"); + + var scenario = new Probe(provider); + Should.Throw(() => scenario.EventStore); + var unrouted = await Should.ThrowAsync(() => scenario.Route(typeof(Unclaimed))); + unrouted.Message.ShouldContain("names no default store"); + } + + [Fact] + public async Task ancillary_stores_alone_keep_the_refusal() + { + var left = ProductStore.Owning("left"); + var right = ProductStore.Owning("right"); + var provider = ancillary(ancillary(services(), left), right).BuildServiceProvider(); + + provider.MainEventStores().ShouldBeEmpty(); + + var ex = Should.Throw(() => provider.EventStore()); + ex.Message.ShouldContain("none of them is registered as the application's main store"); + ex.Message.ShouldContain("'left'"); + ex.Message.ShouldContain("[ancillary]"); + ex.Message.ShouldContain("In()"); + + var unrouted = await Should.ThrowAsync(() => new Probe(provider).Route(typeof(Unclaimed))); + unrouted.Message.ShouldContain("names no default store"); + } + + [Fact] + public void a_single_store_is_the_default_whatever_its_role() + { + var only = ProductStore.Owning("iinvoicingstore"); + var provider = ancillary(services(), only).BuildServiceProvider(); + + provider.EventStore().ShouldBeSameAs(only); + new Probe(provider).EventStore.ShouldBeSameAs(only); + } + + [Fact] + public void a_store_with_no_document_store_interface_is_never_a_main_store() + { + // A hand-written store named "main" is still not a main store: the role comes from the registration + var provider = new ServiceCollection() + .AddSingleton(new FakeEventStore("main")) + .AddSingleton(new FakeEventStore("other")) + .BuildServiceProvider(); + + provider.MainEventStores().ShouldBeEmpty(); + Should.Throw(() => provider.EventStore()); + } + + // ---- waits ---------------------------------------------------------------------------------- + + // Two databases each, so the wait goes to them rather than first asking the read-only view whether it is empty + private static IEventDatabase[] databases() => [Substitute.For(), Substitute.For()]; + + private static void shouldHaveWaited(IEnumerable databases, bool waited) + { + foreach (var database in databases) + { + if (waited) database.Received().WaitForNonStaleProjectionDataAsync(Arg.Any()); + else database.DidNotReceive().WaitForNonStaleProjectionDataAsync(Arg.Any()); + } + } + + [Fact] + public async Task the_catch_up_waits_on_the_main_store_and_never_on_an_ancillary_store_the_specification_never_touched() + { + var app = ProductStore.Owning("main", aggregate: typeof(Wallet)); + var console = ProductStore.Owning("icritterwatchstore", aggregate: typeof(Invoice)); + app.Databases = databases(); + console.Databases = databases(); + // An ancillary store whose daemon never catches up — it is never asked + foreach (var database in console.Databases) + database.WaitForNonStaleProjectionDataAsync(Arg.Any()).Returns(new TaskCompletionSource().Task); + + var scenario = new Probe(ancillary(main(services(), app), console).BuildServiceProvider()); + + await scenario.Route(typeof(Wallet)); + await scenario.AfterAsyncDaemonsCatchUpAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + shouldHaveWaited(app.Databases, true); + shouldHaveWaited(console.Databases, false); + } + + [Fact] + public async Task the_catch_up_waits_on_an_ancillary_store_once_the_specification_touches_it() + { + var app = ProductStore.Owning("main", aggregate: typeof(Wallet)); + var invoicing = ProductStore.Owning("iinvoicingstore", aggregate: typeof(Invoice)); + app.Databases = databases(); + invoicing.Databases = databases(); + var provider = ancillary(main(services(), app), invoicing).BuildServiceProvider(); + + // A step routed to it + var routed = new Probe(provider); + await routed.Route(typeof(Invoice)); + await routed.AfterAsyncDaemonsCatchUpAsync(); + shouldHaveWaited(invoicing.Databases, true); + + // A view pinned to it + invoicing.Databases = databases(); + var viewed = new Probe(provider); + _ = viewed.In(); + await viewed.AfterAsyncDaemonsCatchUpAsync(); + shouldHaveWaited(invoicing.Databases, true); + + // ResetAsync forgets what the last specification touched + invoicing.Databases = databases(); + app.Databases = databases(); + app.ReadOnlyView = Substitute.For(); + await viewed.ResetAsync(); + await viewed.AfterAsyncDaemonsCatchUpAsync(); + shouldHaveWaited(app.Databases, true); + shouldHaveWaited(invoicing.Databases, false); + } + + [Fact] + public async Task with_no_default_store_the_catch_up_still_waits_on_every_store() + { + var left = ProductStore.Owning("left"); + var right = ProductStore.Owning("right"); + left.Databases = databases(); + right.Databases = databases(); + + var scenario = new Probe(ancillary(ancillary(services(), left), right).BuildServiceProvider()); + await scenario.AfterAsyncDaemonsCatchUpAsync(); + + shouldHaveWaited(left.Databases, true); + shouldHaveWaited(right.Databases, true); + } +} + diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/main_store_default_on_marten.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/main_store_default_on_marten.cs new file mode 100644 index 000000000..81c7fa8bb --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/main_store_default_on_marten.cs @@ -0,0 +1,161 @@ +using System.Diagnostics; +using JasperFx; +using JasperFx.Events; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +// An application's main Marten store beside an ancillary store it does not own, the way an embedded CritterWatch +// console registers its own (AddMartenStore). The scenario names no store: the main store is +// the default, and the console's store — whose async projection never catches up here, because nothing runs its +// daemon — holds up no wait. + +/// The ancillary store's marker, standing in for CritterWatch's ICritterWatchStore. +public interface IConsoleStore : Marten.IDocumentStore; + +public record ConsoleEntryRecorded(Guid EntryId, string Text); + +/// The console store's async read model. Nothing runs that store's daemon, so it is always behind. +public class ConsoleSummary +{ + public Guid Id { get; set; } + public int Entries { get; set; } + + public void Apply(ConsoleEntryRecorded e) + { + Id = e.EntryId; + Entries++; + } +} + +public sealed class MartenMainAndConsoleHost : StoreHost +{ + private static int _hosts; + private readonly string _schema = $"bobcat_cs_main_{Interlocked.Increment(ref _hosts)}"; + + public override bool IsAvailable => PostgresEnvironment.IsCi || PostgresEnvironment.IsAvailable; + + public override string SkipReason => PostgresEnvironment.SkipReason; + + protected override IHost Build() + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + builder.Services.AddMarten(opts => + { + opts.Connection(PostgresEnvironment.ConnectionString); + opts.DatabaseSchemaName = _schema; + opts.AutoCreateSchemaObjects = AutoCreate.All; + opts.DisableNpgsqlLogging = true; + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + }) + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + // No AddAsyncDaemon: this store's async projection never moves + builder.Services.AddMartenStore(opts => + { + opts.Connection(PostgresEnvironment.ConnectionString); + opts.DatabaseSchemaName = _schema + "_console"; + opts.AutoCreateSchemaObjects = AutoCreate.All; + opts.DisableNpgsqlLogging = true; + + opts.Projections.Snapshot(SnapshotLifecycle.Async); + }) + .ApplyAllDatabaseChangesOnStartup(); + + return builder.Build(); + } + + /// Put the console store behind: an event its never-running projection will never reach. + public async Task PutTheConsoleBehindAsync() + { + var console = Host.Services.GetRequiredService(); + await using var session = console.LightweightSession(); + var entry = Guid.NewGuid(); + session.Events.StartStream(entry, new ConsoleEntryRecorded(entry, "the console's own traffic")); + await session.SaveChangesAsync(); + } + + protected override async Task BeforeStartAsync() + { + await MartenDdl.Gate.WaitAsync(); + await dropSchemasAsync(); + } + + protected override void AfterStartAttempt() => MartenDdl.Gate.Release(); + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (IsAvailable) await dropSchemasAsync(); + } + + private async Task dropSchemasAsync() + { + await using var connection = new Npgsql.NpgsqlConnection(PostgresEnvironment.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"DROP SCHEMA IF EXISTS {_schema} CASCADE; DROP SCHEMA IF EXISTS {_schema}_console CASCADE"; + await command.ExecuteNonQueryAsync(); + } +} + +public class main_store_default_on_marten(MartenMainAndConsoleHost host) : IClassFixture +{ + private CritterStackScenario newScenario() + { + Assert.SkipUnless(host.IsAvailable, host.SkipReason); + return new CritterStackScenario(host.Host) { ProjectionTimeout = TimeSpan.FromSeconds(10) }; + } + + private IEventStore main => host.Host.Services.EventStore("main"); + + [Fact] + public async Task an_unqualified_specification_uses_the_main_store_and_its_waits_ignore_the_console_store() + { + var scenario = newScenario(); + await host.PutTheConsoleBehindAsync(); + + host.Host.Services.EventStores().Count.ShouldBe(2); + host.Host.Services.EventStore().ShouldBeSameAs(main); + scenario.EventStore.ShouldBeSameAs(main); + + var wallet = Guid.NewGuid(); + var stray = Guid.NewGuid(); + var clock = Stopwatch.StartNew(); + + var recording = await Recordings.RecordAsync(async () => + { + await scenario.GivenEvents(wallet, new WalletOpened(wallet, "Di")); + // A document no store registers, and a stream named by its id alone: both go to the main store + await scenario.GivenReadModel(new Stray { Id = stray }); + + await scenario.WhenActing("the wallet is credited", + services => EventStoreAuthoring.AppendAsync(services.EventStore(), typeof(Wallet), wallet, [new WalletCredited(wallet, 7m)])); + + scenario.ThenEvents(new WalletCredited(wallet, 7m)); + await scenario.AfterAsyncDaemonsCatchUpAsync(); + await scenario.ThenReadModel(wallet, Specifications.Specify().With(x => x.Credits, 1)); + await scenario.ThenAggregate(wallet, Specifications.Specify().With(x => x.Owner, "Di")); + }); + + recording.GatheredFailures().ShouldBeNull(); + (await scenario.TheStreamVersion(wallet)).ShouldBe(2); + (await EventStoreAuthoring.LoadDocumentAsync(main, stray)).ShouldNotBeNull(); + + // Well inside one console-store timeout: the console store was never waited on + clock.Elapsed.ShouldBeLessThan(scenario.ProjectionTimeout); + + // ...and it would have stalled the wait: addressed explicitly, it times out + var console = scenario.In(); + console.ProjectionTimeout = TimeSpan.FromSeconds(1); + await Should.ThrowAsync(() => console.AfterAsyncDaemonsCatchUpAsync()); + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/resetting_under_a_daemon.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/resetting_under_a_daemon.cs new file mode 100644 index 000000000..afd36c12d --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/resetting_under_a_daemon.cs @@ -0,0 +1,91 @@ +using JasperFx.Events; +using JasperFx.Events.Daemon; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +// bobcat#680: the reset pauses every projection coordinator around the delete and resumes it afterwards, as +// Marten's ResetAllMartenDataAsync does, so an async daemon never sees its store emptied under it. Against fakes +// registered the way Marten and Fisher register theirs; the real-daemon half is resetting_a_scenario. + +public class resetting_under_a_daemon +{ + private sealed class LoggingStore(List log, string name, bool fails = false) : FakeEventStore(name) + { + public Ops Advanced { get; } = new(log, name, fails); + + public sealed class Ops(List log, string name, bool fails) + { + public Task ResetAllDataAsync(CancellationToken token) + { + log.Add($"reset {name}"); + return fails ? Task.FromException(new InvalidOperationException("the database went away")) : Task.CompletedTask; + } + } + } + + private static IProjectionCoordinator coordinator(List log, string name) + { + var coordinator = Substitute.For(); + coordinator.PauseAsync().Returns(_ => { log.Add($"pause {name}"); return Task.CompletedTask; }); + coordinator.ResumeAsync().Returns(_ => { log.Add($"resume {name}"); return Task.CompletedTask; }); + return coordinator; + } + + [Fact] + public async Task every_coordinator_is_paused_before_any_store_is_reset_and_resumed_after() + { + var log = new List(); + var main = coordinator(log, "main"); + var ancillary = coordinator(log, "ancillary"); + + var services = new ServiceCollection(); + services.AddSingleton(new LoggingStore(log, "main")); + services.AddSingleton(new LoggingStore(log, "other")); + // The main store's coordinator under the JasperFx interface and as a hosted service (Marten, Fisher) + services.AddSingleton(main); + services.AddSingleton(s => s.GetRequiredService()); + // An ancillary store's only as a hosted service (Marten's AddMartenStore, Fisher's AddFisherStore) + services.AddSingleton(ancillary); + + var provider = services.BuildServiceProvider(); + provider.ProjectionCoordinators().ShouldBe([main, ancillary]); + + await provider.ResetEventStoresAsync(); + + log.ShouldBe(["pause main", "pause ancillary", "reset main", "reset other", "resume main", "resume ancillary"]); + } + + [Fact] + public async Task the_coordinators_are_resumed_when_a_reset_throws() + { + var log = new List(); + var services = new ServiceCollection(); + services.AddSingleton(new LoggingStore(log, "main", fails: true)); + services.AddSingleton(coordinator(log, "main")); + + var provider = services.BuildServiceProvider(); + + await Should.ThrowAsync(() => provider.ResetEventStoresAsync()); + + log.ShouldBe(["pause main", "reset main", "resume main"]); + } + + [Fact] + public async Task a_host_with_no_daemon_resets_as_before() + { + var log = new List(); + var services = new ServiceCollection(); + services.AddSingleton(new LoggingStore(log, "main")); + + var provider = services.BuildServiceProvider(); + provider.ProjectionCoordinators().ShouldBeEmpty(); + + await provider.ResetEventStoresAsync(); + + log.ShouldBe(["reset main"]); + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/single_read_model_kind.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/single_read_model_kind.cs new file mode 100644 index 000000000..55b4c5d79 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/single_read_model_kind.cs @@ -0,0 +1,38 @@ +using Bobcat.Engine; +using JasperFx.Descriptors; +using JasperFx.Events; +using JasperFx.Events.Descriptors; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +// bobcat#672: ThenSingleReadModel() refuses a multi-stream projection before bobcat#616's no-stream reading, so +// the misuse is a SpecCriticalException rather than a wrong about how many T the store holds. A fake store, no database. + +public class single_read_model_kind +{ + /// A view projected across streams. + public class CustomerRollup + { + public Guid Id { get; set; } + } + + [Fact] + public async Task a_multi_stream_projection_is_refused_even_when_the_scenario_has_no_stream() + { + var store = new FakeEventStore("main") { Usage = new EventStoreUsage() }; + store.Usage.Subscriptions.Add(new SubscriptionDescriptor(SubscriptionType.MultiStreamProjection) + { + AggregateType = TypeDescriptor.For(typeof(CustomerRollup)) + }); + + var services = new ServiceCollection(); + services.AddSingleton(store); + var scenario = new CritterStackScenario(services.BuildServiceProvider()); + + var refused = await Should.ThrowAsync(() => scenario.ThenSingleReadModel()); + refused.Message.ShouldContain("reads a single-stream projection, but CustomerRollup is a MultiStreamProjection"); + refused.Message.ShouldContain("ThenReadModel(id, ...)"); + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/store_routing.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/store_routing.cs new file mode 100644 index 000000000..e27211b1e --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/store_routing.cs @@ -0,0 +1,211 @@ +using Bobcat.CritterStack; +using Bobcat.Engine; +using JasperFx.Descriptors; +using JasperFx.Events; +using JasperFx.Events.Descriptors; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace Bobcat.CritterStack.ScenarioTests; + +// bobcat#504: how a step's store is chosen, against fake stores that describe themselves — no database. The same +// rules run against real Marten and Fisher stores in ancillary_stores. + +public class store_routing +{ + // The protected routing seam, as a derived scenario such as WolverineFx.Bobcat's reaches it + private sealed class Probe(IServiceProvider services, string? storeName = null) : CritterStackScenario(services, storeName) + { + public Task Route(Type? model, params object[] values) => storeForAsync("the step", model, values); + public Task RouteTags(params Type[] tags) => storeForAsync("the step", null, null, tags); + public IReadOnlyList Observed => storesToObserve(); + } + + /// A fake store that also describes its documents, as Marten, Polecat and Fisher do. + private sealed class DocumentedStore(string name) : FakeEventStore(name), IDocumentStoreUsageSource + { + public DocumentStoreUsage Documents { get; } = new(); + + Task IDocumentStoreUsageSource.TryCreateUsage(CancellationToken token) + => Task.FromResult(Documents); + } + + private static IServiceProvider servicesWith(params IEventStore[] stores) + { + var services = new ServiceCollection(); + foreach (var store in stores) services.AddSingleton(store); + return services.BuildServiceProvider(); + } + + private static TypeDescriptor descriptor(Type type) => TypeDescriptor.For(type); + + private static DocumentedStore storeOwning(string name, Type? aggregate = null, Type? @event = null, Type? document = null, + Type? tag = null) + { + var store = new DocumentedStore(name) { Usage = new EventStoreUsage() }; + if (aggregate is not null) + { + store.Usage.Subscriptions.Add(new SubscriptionDescriptor(SubscriptionType.SingleStreamProjection) + { + AggregateType = descriptor(aggregate), + AppliedEvents = @event is null ? [] : [descriptor(@event)] + }); + } + else if (@event is not null) + { + store.Usage.RegisteredEventTypes.Add(new EventTypeDescriptor(descriptor(@event), @event.Name, null)); + } + + if (document is not null) store.Documents.Documents.Add(new DocumentMappingDescriptor { DocumentType = descriptor(document) }); + if (tag is not null) store.Usage.TagTypes.Add(new TagTypeDescriptor { TagType = tag.FullName! }); + return store; + } + + [Fact] + public async Task a_single_store_is_used_as_before_and_never_asked_what_it_owns() + { + var store = new FakeEventStore("main"); + var scenario = new Probe(servicesWith(store)); + + (await scenario.Route(typeof(Unclaimed))).ShouldBeSameAs(store); + (await scenario.Route(null, new InvoiceRaised(Guid.NewGuid(), 1m))).ShouldBeSameAs(store); + scenario.Observed.ShouldBe([store]); + store.UsageReads.ShouldBe(0); + + // In() still works on a host with one store + scenario.In("main").EventStore.ShouldBeSameAs(store); + } + + [Fact] + public async Task the_one_store_that_claims_a_type_takes_it() + { + var main = storeOwning("main", aggregate: typeof(Wallet), @event: typeof(WalletOpened)); + var invoicing = storeOwning("invoicing", aggregate: typeof(Invoice), @event: typeof(InvoiceRaised), document: typeof(SharedNote)); + var scenario = new Probe(servicesWith(main, invoicing)); + + (await scenario.Route(typeof(Invoice))).ShouldBeSameAs(invoicing); + (await scenario.Route(typeof(Wallet))).ShouldBeSameAs(main); + (await scenario.Route(typeof(SharedNote))).ShouldBeSameAs(invoicing); + + // No model: the events decide, whole, partial or as a type + (await scenario.Route(null, new InvoiceRaised(Guid.NewGuid(), 1m))).ShouldBeSameAs(invoicing); + (await scenario.Route(null, Specifications.Specify())).ShouldBeSameAs(main); + (await scenario.Route(null, typeof(InvoiceRaised))).ShouldBeSameAs(invoicing); + + // An unregistered aggregate type: its events decide + (await scenario.Route(typeof(Unclaimed), new WalletOpened(Guid.NewGuid(), "x"))).ShouldBeSameAs(main); + + scenario.Observed.ShouldBe([main, invoicing]); + } + + [Fact] + public async Task tag_types_decide_too() + { + var main = storeOwning("main", tag: typeof(ScreeningId)); + var other = storeOwning("other"); + var scenario = new Probe(servicesWith(main, other)); + + (await scenario.RouteTags(typeof(ScreeningId))).ShouldBeSameAs(main); + } + + [Fact] + public async Task the_routing_is_read_once_per_specification() + { + var main = storeOwning("main", aggregate: typeof(Wallet)); + var invoicing = storeOwning("invoicing", aggregate: typeof(Invoice)); + var scenario = new Probe(servicesWith(main, invoicing)); + + await scenario.Route(typeof(Invoice)); + await scenario.Route(typeof(Invoice)); + await scenario.Route(typeof(Wallet)); + + main.UsageReads.ShouldBe(1); + invoicing.UsageReads.ShouldBe(1); + } + + [Fact] + public async Task several_or_no_claimants_fall_back_to_the_default_store() + { + var main = storeOwning("main", document: typeof(SharedNote)); + var invoicing = storeOwning("invoicing", aggregate: typeof(Invoice), document: typeof(SharedNote)); + var scenario = new Probe(servicesWith(main, invoicing), storeName: "main"); + + (await scenario.Route(typeof(SharedNote))).ShouldBeSameAs(main); + (await scenario.Route(typeof(Unclaimed))).ShouldBeSameAs(main); + (await scenario.Route(null)).ShouldBeSameAs(main); + (await scenario.Route(typeof(Invoice))).ShouldBeSameAs(invoicing); + } + + [Fact] + public async Task several_claimants_without_the_default_among_them_fail_listing_them() + { + var main = storeOwning("main"); + var left = storeOwning("left", document: typeof(SharedNote)); + var right = storeOwning("right", document: typeof(SharedNote)); + var scenario = new Probe(servicesWith(main, left, right), storeName: "main"); + + var ex = await Should.ThrowAsync(() => scenario.Route(typeof(SharedNote))); + ex.Message.ShouldContain("2 event stores register SharedNote ('left'"); + ex.Message.ShouldContain("'right'"); + ex.Message.ShouldContain("scenario.In()"); + ex.Message.ShouldNotContain("names no default store"); + } + + [Fact] + public async Task the_events_narrow_a_model_several_stores_claim() + { + var left = storeOwning("left", aggregate: typeof(SharedNote), @event: typeof(InvoiceRaised)); + var right = storeOwning("right", aggregate: typeof(SharedNote), @event: typeof(WalletOpened)); + var scenario = new Probe(servicesWith(left, right)); + + (await scenario.Route(typeof(SharedNote), new WalletOpened(Guid.NewGuid(), "x"))).ShouldBeSameAs(right); + await Should.ThrowAsync(() => scenario.Route(typeof(SharedNote))); + } + + [Fact] + public async Task a_store_that_cannot_describe_itself_owns_nothing() + { + var main = new FakeEventStore("main"); // describes itself as null + var invoicing = storeOwning("invoicing", aggregate: typeof(Invoice)); + var scenario = new Probe(servicesWith(main, invoicing)); + + (await scenario.Route(typeof(Invoice))).ShouldBeSameAs(invoicing); + var ex = await Should.ThrowAsync(() => scenario.Route(typeof(Wallet))); + ex.Message.ShouldContain("no event store registers Wallet"); + } + + [Fact] + public async Task a_view_is_pinned_whatever_its_steps_are_about() + { + var main = storeOwning("main", aggregate: typeof(Wallet)); + var invoicing = storeOwning("invoicing", aggregate: typeof(Invoice)); + var scenario = new CritterStackScenario(servicesWith(main, invoicing)); + + var view = scenario.In("invoicing"); + view.EventStore.ShouldBeSameAs(invoicing); + view.In("main").EventStore.ShouldBeSameAs(main); + view.ProjectionTimeout = TimeSpan.FromSeconds(3); + scenario.ProjectionTimeout.ShouldBe(TimeSpan.FromSeconds(3)); + main.UsageReads.ShouldBe(0); + } + + [Fact] + public void a_marker_that_resolves_to_no_event_store_is_named() + { + var scenario = new CritterStackScenario(servicesWith(new FakeEventStore("main"), new FakeEventStore("invoicing"))); + + var ex = Should.Throw(() => scenario.In()); + ex.Message.ShouldContain("No event store is registered under IInvoicingStore"); + ex.Message.ShouldContain("'invoicing'"); + } + + [Fact] + public void a_marker_is_matched_by_the_store_named_after_it_when_it_resolves_to_something_else() + { + // Fisher registers a proxy under the marker and the unwrapped store as IEventStore, named after the marker + var ancillary = new FakeEventStore("iinvoicingstore"); + var scenario = new CritterStackScenario(servicesWith(new FakeEventStore("main"), ancillary)); + + scenario.In().EventStore.ShouldBeSameAs(ancillary); + } +} diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/tenants.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/tenants.cs new file mode 100644 index 000000000..7d4be3828 --- /dev/null +++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/tenants.cs @@ -0,0 +1,205 @@ +using Fisher; +using JasperFx; +using JasperFx.Events; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using JasperFx.MultiTenancy; +using Marten; +using Microsoft.Extensions.Hosting; +using Shouldly; +using static Bobcat.Specifications; + +namespace Bobcat.CritterStack.ScenarioTests; + +// bobcat#536: on a conjoined multi-tenant store, ForTenant(id) arranges and reads as one tenant. Without it the +// Givens appended to the default tenant and ThenAggregate read a stream the tenant's act never wrote. Each test +// runs on Marten and on Fisher through the classes at the bottom. + +public abstract class tenants(THost host) where THost : StoreHost +{ + private CritterStackScenario newScenario() + { + Assert.SkipUnless(host.IsAvailable, host.SkipReason); + return new CritterStackScenario(host.Host); + } + + [Fact] + public async Task a_tenants_givens_and_thens_write_and_read_that_tenants_streams() + { + var scenario = newScenario(); + var red = scenario.ForTenant("red"); + var blue = scenario.ForTenant("blue"); + var wallet = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await red.GivenEvents(wallet, new WalletOpened(wallet, "Ann"), new WalletCredited(wallet, 10m)); + await red.GivenEventsOn(wallet, new WalletCredited(wallet, 5m)); + + await red.ThenAggregate(wallet, Specify().With(x => x.Balance, 15m)); + await red.ThenEventsOn(wallet, 2L, new WalletCredited(wallet, 5m)); + (await red.TheAggregate(wallet))!.Owner.ShouldBe("Ann"); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Lines().ShouldContain(x => x.StartsWith("Given ") && x.EndsWith("for tenant \"red\"")); + recording.Lines().Where(x => x.StartsWith("Then ")).ShouldAllBe(x => x.EndsWith("for tenant \"red\"")); + + (await EventStores.FetchStreamAsync(scenario.EventStore, wallet, "red")).Count.ShouldBe(3); + (await EventStores.FetchStreamAsync(scenario.EventStore, wallet, "blue")).Count.ShouldBe(0); + (await blue.TheAggregate(wallet)).ShouldBeNull(); + } + + [Fact] + public async Task a_tenants_documents_and_projected_read_models_are_that_tenants() + { + var scenario = newScenario(); + var red = scenario.ForTenant("red"); + var blue = scenario.ForTenant("blue"); + var customer = Guid.NewGuid(); + var wallet = Guid.NewGuid(); + + var recording = await Recordings.RecordAsync(async () => + { + await red.GivenReadModel(new Customer { Id = customer, Name = "Ann", City = "Austin" }); + await red.ThenDocument(customer, Specify().With(x => x.City, "Austin")); + await blue.ThenNoReadModel(customer); + await blue.ThenNoReadModel(); + + // An async projection: the read waits on the tenant's database first + await red.GivenEvents(wallet, new WalletOpened(wallet, "Ann"), new WalletCredited(wallet, 3m)); + await red.ThenReadModel(wallet, Specify().With(x => x.Credits, 1)); + await red.ThenSingleReadModel(Specify().With(x => x.Balance, 3m)); + await red.AfterAsyncDaemonsCatchUpAsync(); + await blue.ThenNoReadModel(wallet); + }); + + recording.GatheredFailures().ShouldBeNull(); + recording.Lines().ShouldContain("Given the async daemons have caught up for tenant \"red\""); + + (await EventStoreAuthoring.LoadDocumentAsync(scenario.EventStore, customer, "red")).ShouldNotBeNull(); + (await EventStoreAuthoring.LoadDocumentAsync(scenario.EventStore, customer, "blue")).ShouldBeNull(); + } + + [Fact] + public void a_tenant_view_shares_the_specification_and_names_its_tenant() + { + var scenario = newScenario(); + + scenario.TenantId.ShouldBeNull(); + var red = scenario.ForTenant("red"); + red.TenantId.ShouldBe("red"); + red.EventStore.ShouldBeSameAs(scenario.EventStore); + red.ForTenant("blue").TenantId.ShouldBe("blue"); + + // A store view and a tenant view compose, in either order + var name = scenario.EventStore.Identity.Name; + red.In(name).TenantId.ShouldBe("red"); + scenario.In(name).ForTenant("red").EventStore.ShouldBeSameAs(scenario.EventStore); + Should.Throw(() => scenario.ForTenant(" ")); + } +} + +public class tenants_on_marten(MartenTenantedStoreHost host) : tenants(host), IClassFixture; + +public class tenants_on_fisher(FisherTenantedStoreHost host) : tenants(host), IClassFixture; + +/// Marten with conjoined tenancy for events and documents, in a schema of its own. +public sealed class MartenTenantedStoreHost : StoreHost +{ + private static int _hosts; + private readonly string _schema = $"bobcat_cs_tenants_{Interlocked.Increment(ref _hosts)}"; + + public override bool IsAvailable => PostgresEnvironment.IsCi || PostgresEnvironment.IsAvailable; + + public override string SkipReason => PostgresEnvironment.SkipReason; + + protected override IHost Build() + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + builder.Services.AddMarten(opts => + { + opts.Connection(PostgresEnvironment.ConnectionString); + opts.DatabaseSchemaName = _schema; + opts.AutoCreateSchemaObjects = AutoCreate.All; + opts.DisableNpgsqlLogging = true; + + opts.Events.TenancyStyle = TenancyStyle.Conjoined; + opts.Policies.AllDocumentsAreMultiTenanted(); + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + opts.Schema.For(); + }) + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + return builder.Build(); + } + + protected override async Task BeforeStartAsync() + { + await MartenDdl.Gate.WaitAsync(); + await dropSchemaAsync(); + } + + protected override void AfterStartAttempt() => MartenDdl.Gate.Release(); + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (IsAvailable) await dropSchemaAsync(); + } + + private async Task dropSchemaAsync() + { + await using var connection = new Npgsql.NpgsqlConnection(PostgresEnvironment.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"DROP SCHEMA IF EXISTS {_schema} CASCADE"; + await command.ExecuteNonQueryAsync(); + } +} + +/// Fisher with conjoined tenancy for events and documents, on a temp SQLite file. +public sealed class FisherTenantedStoreHost : StoreHost +{ + private readonly string _file = Path.Combine(Path.GetTempPath(), $"bobcat-critterstack-tenants-{Guid.NewGuid():N}.db"); + + protected override IHost Build() + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + builder.Services.AddFisher(opts => + { + opts.Connection($"Data Source={_file}"); + + opts.Events.TenancyStyle = TenancyStyle.Conjoined; + opts.Policies.AllDocumentsAreMultiTenanted(); + + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + opts.Schema.For(); + }) + .ApplyAllDatabaseChangesOnStartup() + .AddAsyncDaemon(DaemonMode.Solo); + + return builder.Build(); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + + foreach (var file in new[] { _file, _file + "-wal", _file + "-shm" }) + { + try + { + File.Delete(file); + } + catch (IOException) + { + // A temp file; the OS reclaims it + } + } + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Boards.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Boards.cs new file mode 100644 index 000000000..b40256101 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Boards.cs @@ -0,0 +1,215 @@ +namespace Wolverine.Bobcat.EventModel.Tests; + +/// +/// The boards Bobcat.EventModel's own tests import, copied here with the spec writer's tests (GH-5082) because the +/// specification half of those tests moved with the writer. Same text, so the two halves are tested on one board. +/// +internal static class Boards +{ + /// The Shelter board Bobcat's AggregateInferenceTests imports: two chapters, streams inferred from the examples. + internal const string Shelter = + """ + slices: + Volunteering: + steps: + - c: Volunteer / Apply to volunteer + - e: Volunteer / Volunteer application submitted + - c: Admin / Review volunteer application + - e: Admin / Volunteer application reviewed + - c: Admin / Approve volunteer + - e: Admin / Volunteer approved + - c: Admin / Reject volunteer application + - e: Admin / Volunteer application rejected + - c: Staff / Request home check + - e: Staff / Home check requested + - c: Volunteer / Accept home check assignment + - e: Volunteer / Home check assignment accepted + tests: + Submitted: + when: + - c: Apply to volunteer + then: + - e: Volunteer application submitted + Reviewed: + given: + - e: Volunteer application submitted + when: + - c: Review volunteer application + then: + - e: Volunteer application reviewed + Approved: + given: + - e: Volunteer application reviewed + when: + - c: Approve volunteer + then: + - e: Volunteer approved + Rejected: + given: + - e: Volunteer application reviewed + when: + - c: Reject volunteer application + then: + - e: Volunteer application rejected + Requested: + given: + - e: Application reviewed + when: + - c: Request home check + then: + - e: Home check requested + Accepted: + given: + - e: Home check requested + - e: Volunteer approved + when: + - c: Accept home check assignment + then: + - e: Home check assignment accepted + Booking: + steps: + - c: System / Propose home check appointment + - e: System / Home check appointment proposed + - c: System / Propose foster handover appointment + - e: System / Foster handover appointment proposed + - c: Member / Confirm appointment + - e: Member / Appointment confirmed + - c: Staff / Cancel appointment + - e: Staff / Appointment cancelled + tests: + Home check proposed: + when: + - c: Propose home check appointment + then: + - e: Home check appointment proposed + Foster proposed: + when: + - c: Propose foster handover appointment + then: + - e: Foster handover appointment proposed + Confirmed: + given: + - e: Home check appointment proposed + when: + - c: Confirm appointment + then: + - e: Appointment confirmed + Cancelled: + given: + - e: Appointment confirmed + when: + - c: Cancel appointment + then: + - e: Appointment cancelled + """; + + /// A command that starts a stream whose handler mints the id (StreamStartIdentityTests). + internal const string MintedIdentity = + """ + { "slices": [ { "title": "Apply", "aggregates": ["Application"], + "commands": [ { "title": "Submit Application", "aggregate": "Application", "fields": [ + { "name": "areas", "type": "String" } ] } ], + "events": [ { "title": "Application Submitted", "aggregate": "Application", "fields": [ + { "name": "applicationId", "type": "UUID", "idAttribute": true }, + { "name": "areas", "type": "String" } ] } ], + "specifications": [ { "title": "Submits", + "given": [], + "when": [ { "title": "Submit Application", "type": "COMMAND", "fields": [ { "name": "areas", "example": "dogs" } ] } ], + "then": [ { "title": "Application Submitted", "type": "EVENT", "fields": [ { "name": "areas", "example": "dogs" } ] } ] } ] } ] } + """; + + /// A string-keyed stream, started with the id its command carries (StreamStartIdentityTests). + internal const string StreamStartNaturalKey = + """ + { "slices": [ { "title": "Register", "aggregates": ["Customer"], + "commands": [ { "title": "Register Customer", "aggregate": "Customer", "fields": [ + { "name": "name", "type": "String" } ] } ], + "events": [ { "title": "Customer Registered", "aggregate": "Customer", "fields": [ + { "name": "email", "type": "String", "idAttribute": true }, + { "name": "name", "type": "String" } ] } ], + "specifications": [ { "title": "Registers", + "given": [], + "when": [ { "title": "Register Customer", "type": "COMMAND", "fields": [ { "name": "name", "example": "Ann" } ] } ], + "then": [ { "title": "Customer Registered", "type": "EVENT", "fields": [ { "name": "name", "example": "Ann" } ] } ] } ] } ] } + """; + + /// The Shop board StrongTypedIdentityTests imports. + internal const string Ordering = + """ + slices: + Ordering: + steps: + - c: Member/Place Order + props: { orderId: order-123, total: 12.5 } + - e: Member/Order Placed + props: { orderId: order-123 } + - c: Member/Cancel Order + props: { orderId: order-123 } + - e: Member/Order Cancelled + - v: Member/Order Summary + tests: + AnOrderIsCancelled: + given: [{ e: Member/Order Placed }] + when: [{ c: Member/Cancel Order }] + then: [{ e: Member/Order Cancelled }] + """; + + /// A natural key under --strong-typed-ids (StrongTypedIdentityTests). + internal const string StrongTypedNaturalKey = + """ + { "slices": [ { "title": "Register", "aggregates": ["Customer"], + "commands": [ { "title": "Register Customer", "aggregate": "Customer", "fields": [ + { "name": "name", "type": "String" } ] } ], + "events": [ { "title": "Customer Registered", "aggregate": "Customer", "fields": [ + { "name": "email", "type": "String", "idAttribute": true }, + { "name": "name", "type": "String" } ] } ], + "specifications": [ { "title": "Registers", + "given": [], + "when": [ { "title": "Register Customer", "type": "COMMAND", "fields": [ { "name": "name", "example": "Ann" } ] } ], + "then": [ { "title": "Customer Registered", "type": "EVENT", "fields": [ { "name": "name", "example": "Ann" } ] } ] } ] } ] } + """; + + /// An eventmodelers.ai config.json export (EventModelersJsonReaderTests). + internal const string Config = + """ + { + "context": "Shop", + "slices": [ + { + "title": "Add Item", + "sliceType": "STATE_CHANGE", + "aggregates": ["Cart"], + "screens": [ { "title": "Cart Page", "fields": [] } ], + "commands": [ + { "title": "Add Item", "fields": [ + { "name": "cartId", "type": "UUID", "cardinality": "Single", "idAttribute": true }, + { "name": "quantity", "type": "Int", "cardinality": "Single" }, + { "name": "tags", "type": "String", "cardinality": "List" } ] } + ], + "events": [ + { "title": "Item Added", "aggregate": "default", "fields": [ + { "name": "cartId", "type": "UUID", "cardinality": "Single" } ] } + ], + "readmodels": [], + "processors": [], + "specifications": [ + { + "title": "Adds to an empty cart", + "given": [], + "when": [ { "title": "Add Item", "type": "SPEC_COMMAND", + "fields": [ { "name": "cartId", "example": "cart-1" }, { "name": "quantity", "example": "2" }, { "name": "tags" } ] } ], + "then": [ { "title": "Item Added", "type": "SPEC_EVENT", + "fields": [ { "name": "cartId", "example": "cart-1" } ] } ] + }, + { + "title": "Too many", + "given": [], + "when": [ { "title": "Add Item", "type": "COMMAND", "fields": [ { "name": "quantity", "example": "99" } ] } ], + "then": { "title": "Error-Case", "type": "SPEC_ERROR", "description": "Quantity over limit" } + } + ] + } + ] + } + """; +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/DeclaredIdentityTests.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/DeclaredIdentityTests.cs new file mode 100644 index 000000000..c840a1339 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/DeclaredIdentityTests.cs @@ -0,0 +1,145 @@ +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; +using Shouldly; + +namespace Wolverine.Bobcat.EventModel.Tests; + +/// +/// The identity and stream a model declares on an element, used by a generated spec even when the +/// example repeats neither (the Underwriting case: an aggregate and an idAttribute, no values). +/// +public class DeclaredIdentityTests +{ + private static string generate(EmlangBoard board) + { + var model = EmlangImport.ToCurated(board, "Limits").Model; + return EmlangSpecWriter.Write(board, model, "Limits").AllCode(); + } + + private const string ValuelessConfig = + """ + { + "slices": [ + { + "title": "Grant Limit", + "aggregates": ["AuthorityLimit"], + "commands": [ { "title": "Grant Limit", "aggregate": "AuthorityLimit", "fields": [ + { "name": "authorityLimitId", "type": "UUID", "idAttribute": true }, + { "name": "amount", "type": "Decimal" } ] } ], + "events": [ + { "title": "Limit Requested", "aggregate": "AuthorityLimit", "fields": [ + { "name": "authorityLimitId", "type": "UUID", "idAttribute": true } ] }, + { "title": "Limit Granted", "aggregate": "AuthorityLimit", "fields": [ + { "name": "authorityLimitId", "type": "UUID", "idAttribute": true }, + { "name": "amount", "type": "Decimal" } ] } ], + "specifications": [ + { "title": "Grants a requested limit", + "given": [ { "title": "Limit Requested", "type": "SPEC_EVENT", "fields": [] } ], + "when": [ { "title": "Grant Limit", "type": "SPEC_COMMAND", "fields": [ { "name": "amount", "example": "500" } ] } ], + "then": [ { "title": "Limit Granted", "type": "SPEC_EVENT", "fields": [] } ] } + ] + } + ] + } + """; + + [Fact] + public void an_example_that_names_no_stream_takes_the_one_its_element_declares() + { + generate(EventModelersJsonReader.Read(ValuelessConfig)) + .ShouldContain("await GivenEvents(theAuthorityLimit,"); + } + + [Fact] + public void a_marked_identity_with_no_example_value_is_minted_once_and_set_everywhere() + { + var code = generate(EventModelersJsonReader.Read(ValuelessConfig)); + + code.ShouldContain("var theAuthorityLimit = Guid.CreateVersion7();"); + code.ShouldContain("Specify().With(x => x.AuthorityLimitId, theAuthorityLimit)"); + EmlangSpecWriterTests.unindented(code).ShouldContain(EmlangSpecWriterTests.unindented(""" + Specify() + .With(x => x.Amount, 500m) + .With(x => x.AuthorityLimitId, theAuthorityLimit) + """)); + code.ShouldContain("ThenEvents(Specify().With(x => x.AuthorityLimitId, theAuthorityLimit));"); + code.ShouldNotContain("TODO: the model names no"); + } + + [Fact] + public void an_aggregate_id_field_is_its_streams_identity_when_nothing_is_marked() + { + var code = generate(EventModelersJsonReader.Read( + """ + { "slices": [ { "title": "Clear", "aggregates": ["Cart"], + "commands": [ { "title": "Clear Cart", "aggregate": "Cart", "fields": [ { "name": "aggregateId", "type": "UUID" } ] } ], + "events": [ { "title": "Cart Cleared", "aggregate": "Cart", "fields": [ { "name": "aggregateId", "type": "UUID" } ] } ], + "specifications": [ { "title": "Clears", + "given": [ { "title": "Cart Cleared", "type": "EVENT", "fields": [] } ], + "when": [ { "title": "Clear Cart", "type": "COMMAND", "fields": [] } ], + "then": [ { "title": "Cart Cleared", "type": "EVENT", "fields": [] } ] } ] } ] } + """)); + + code.ShouldContain("var theCart = Guid.CreateVersion7();"); + code.ShouldContain("await GivenEvents(theCart, Specify().With(x => x.AggregateId, theCart));"); + } + + [Fact] + public void an_emlang_swimlane_is_an_actor_so_the_stream_is_named_for_the_events_subject() + { + // bobcat#439: K9CRUSH writes `Admin / Volunteer approved`, and reading the swimlane as the + // stream spread one application's events over Admin, Volunteer and ShelterStaff. bobcat#444: + // nor is the stream left without an aggregate type. It is inferred from the events, named + // for their subject, and the act addresses it through the command's Id (bobcat#438). + var code = generate(EmlangReader.Read( + """ + slices: + Place: + steps: + - c: Place order + - e: Admin / Order placed + props: + order id: uuid + tests: + Placed twice: + given: + - e: Order placed + when: + - c: Place order + then: + - x: Already placed + """)); + + code.ShouldContain("var theOrder = Guid.CreateVersion7();"); + code.ShouldContain("await GivenEvents(theOrder, Specify().With(x => x.OrderId, theOrder));"); + code.ShouldContain("await WhenReceived(Specify().With(x => x.Id, theOrder));"); + code.ShouldNotContain("GivenEvents"); + code.ShouldNotContain("TODO: the model names no"); + } + + [Fact] + public void an_example_value_for_the_identity_is_the_streams_local_for_the_elements_that_name_none() + { + var code = generate(EventModelersJsonReader.Read(ValuelessConfig.Replace( + "\"given\": [ { \"title\": \"Limit Requested\", \"type\": \"SPEC_EVENT\", \"fields\": [] } ]", + "\"given\": [ { \"title\": \"Limit Requested\", \"type\": \"SPEC_EVENT\", \"fields\": [ { \"name\": \"authorityLimitId\", \"example\": \"limit-7\" } ] } ]"))); + + code.ShouldContain("var theAuthorityLimit = Guid.CreateVersion7(); // \"limit-7\" in the model"); + code.ShouldContain("ThenEvents(Specify().With(x => x.AuthorityLimitId, theAuthorityLimit));"); + code.ShouldNotContain("theAuthorityLimit2"); + } + + [Fact] + public void a_minted_identity_nothing_uses_is_not_declared() + { + // A view's identity minted for a view no step reads would be an unused local: CS0219 + var code = generate(EventModelersJsonReader.Read( + """ + { "slices": [ { "title": "Dashboard", + "readmodels": [ { "title": "Cell Dashboard", "fields": [ { "name": "cellId", "type": "UUID", "idAttribute": true } ] } ], + "specifications": [ { "title": "Nothing yet", "given": [], "when": [], "then": [] } ] } ] } + """)); + + code.ShouldNotContain("var theCell"); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/EmlangSpecWriterTests.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/EmlangSpecWriterTests.cs new file mode 100644 index 000000000..cb5c6cb4d --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/EmlangSpecWriterTests.cs @@ -0,0 +1,637 @@ +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; +using Shouldly; + +namespace Wolverine.Bobcat.EventModel.Tests; + +/// bobcat#423: an emlang model's examples as WolverineFx.Bobcat specifications. +public class EmlangSpecWriterTests +{ + private const string Kitchen = + """ + slices: + Add item: + steps: + - t: Website / Menu + - c: Add item to order + - e: Order / Item added + - v: Order summary + tests: + Add to an existing order: + given: + - e: Order / Order started + props: + order id: order-123 + when: + - c: Add item to order + props: + order id: order-123 + item: margherita + then: + - e: Order / Item added + props: + order id: order-123 + item: margherita + - v: Order summary + props: + order id: order-123 + items: + - margherita/9.99 + total: 9.99 + Add to a closed order: + given: + - e: Order / Order closed + props: + order id: order-9 + when: + - c: Add item to order + props: + order id: order-9 + then: + - x: Order is closed + View summary: + steps: + - e: Order / Item added + - v: Order summary + tests: + Nothing ordered yet: + given: + then: + """; + + private static (GeneratedSpecs Specs, CSharpModelWriter.Output Stubs) generate(string yaml) + { + var board = EmlangReader.Read(yaml); + var model = EmlangImport.ToCurated(board, "Kitchen").Model; + var specs = EmlangSpecWriter.Write(board, model, "Kitchen"); + return (specs, CSharpModelWriter.Write(model, "Kitchen", specs.Additions)); + } + + private static string code(string yaml) => generate(yaml).Specs.AllCode(); + + [Fact] + public void each_slice_is_a_feature_class_and_each_test_a_fact() + { + var (specs, _) = generate(Kitchen); + + specs.Features.ShouldBe(2); + specs.Specs.ShouldBe(3); + specs.AllCode().ShouldContain("[BobcatFeature(\"AddItemToOrder\")]"); + specs.AllCode().ShouldContain("public class add_item_to_order(AppFixture app) : KitchenSpec(app)"); + specs.AllCode().ShouldContain("public async Task add_to_an_existing_order()"); + } + + [Fact] + public void an_identity_is_one_declared_guid_local_named_for_what_it_identifies() + { + var code = EmlangSpecWriterTests.code(Kitchen); + + code.ShouldContain("var theOrder = Guid.CreateVersion7(); // \"order-123\" in the model"); + + // `Order / Order started` is a swimlane, not a declared stream (bobcat#439); the Order + // aggregate is inferred from what the events are about (bobcat#444) + code.ShouldContain("await GivenEvents(theOrder, Specify().With(x => x.OrderId, theOrder));"); + } + + [Fact] + public void the_act_and_the_expectations_are_partial_objects_of_exactly_what_the_example_names() + { + var code = EmlangSpecWriterTests.code(Kitchen); + + // The command already names its stream's identity, OrderId for the inferred Order, so it + // gets no second Id (bobcat#438, bobcat#444) + code.ShouldContain("await WhenReceived(Specify().With(x => x.OrderId, theOrder).With(x => x.Item, \"margherita\"));"); + code.ShouldNotContain("x => x.Id, theOrder"); + code.ShouldContain("ThenEvents(Specify().With(x => x.OrderId, theOrder).With(x => x.Item, \"margherita\"));"); + } + + [Fact] + public void a_view_is_checked_on_the_document_its_identity_names() + { + // Past 120 columns, the chain puts each member on its own line + unindented(code(Kitchen)).ShouldContain(unindented(""" + await ThenReadModel(theOrder, Specify() + .With(x => x.OrderId, theOrder) + .With(x => x.Items, new List { "margherita/9.99" }) + .With(x => x.Total, 9.99m)); + """)); + } + + [Fact] + public void a_refusal_appends_nothing() + { + var code = EmlangSpecWriterTests.code(Kitchen); + + code.ShouldContain("ThenRefusedWith(\"Order is closed\");\n ThenNoEvents();"); + } + + [Fact] + public void a_view_test_that_expects_nothing_and_names_no_identity_is_no_read_model_at_all() + { + generate(Kitchen).Specs.AllCode().ShouldContain("await ThenNoReadModel();"); + } + + [Fact] + public void the_stubs_carry_what_the_specs_need_streams_document_ids_and_events_only_a_given_names() + { + var stubs = generate(Kitchen).Stubs.AllStubs(); + + // The stream is the inferred Order aggregate, a class (bobcat#444), not the swimlane (bobcat#439) + stubs.ShouldContain("public class Order { public Guid Id { get; set; } }"); + stubs.ShouldContain("public record OrderSummary(Guid Id, Guid OrderId, List Items, decimal Total);"); + + // Named only by a test's given, never by a slice's steps + stubs.ShouldContain("public record OrderClosed(Guid OrderId);"); + } + + [Fact] + public void an_empty_given_with_an_act_names_the_declared_stream_the_act_starts() + { + var board = EventModelersJsonReader.Read( + """ + { "slices": [ { "title": "Open", "aggregates": ["Cart"], + "commands": [ { "title": "Open Cart", "aggregate": "Cart", "fields": [ { "name": "aggregateId", "type": "UUID" } ] } ], + "events": [ { "title": "Cart Opened", "aggregate": "Cart", "fields": [ { "name": "aggregateId", "type": "UUID" } ] } ], + "specifications": [ { "title": "Opens", + "given": [], + "when": [ { "title": "Open Cart", "type": "COMMAND", "fields": [] } ], + "then": [ { "title": "Cart Opened", "type": "EVENT", "fields": [] } ] } ] } ] } + """); + var model = EmlangImport.ToCurated(board, "Carts").Model; + + EmlangSpecWriter.Write(board, model, "Carts").AllCode().ShouldContain("await GivenNoEventsFor(theCart);"); + } + + [Fact] + public void an_empty_given_with_no_declared_stream_arranges_nothing() + { + // A swimlane is not a stream (bobcat#439), so there is no stream type to name as empty + code( + """ + slices: + Start order: + tests: + First item: + when: + - c: Start order + props: + order id: o-1 + then: + - e: Order / Order started + props: + order id: o-1 + """).ShouldNotContain("GivenNoEventsFor"); + } + + [Fact] + public void a_declared_type_shapes_the_literal() + { + var code = EmlangSpecWriterTests.code( + """ + slices: + Book: + steps: + - c: Book session + props: + starts: datetime + seats: int + paid: boolean + tests: + Booked: + when: + - c: Book session + props: + starts: "2026-08-10T09:00:00Z" + seats: 2 + paid: true + """); + + code.ShouldContain(".With(x => x.Starts, DateTimeOffset.Parse(\"2026-08-10T09:00:00Z\"))"); + code.ShouldContain(".With(x => x.Seats, 2)"); + code.ShouldContain(".With(x => x.Paid, true)"); + } + + [Fact] + public void a_value_that_does_not_fit_its_declared_type_still_compiles_and_says_so() + { + EmlangSpecWriter.Literal("int", "lots").ShouldBe("default /* lots in the model */"); + EmlangSpecWriter.Literal("string", "say \"hi\"").ShouldBe("\"say \\\"hi\\\"\""); + } + + [Fact] + public void names_are_snake_case_and_a_single_word_feature_avoids_a_reserved_lowercase_name() + { + EmlangSpecWriter.FeatureClassName("Add item").ShouldBe("add_item"); + EmlangSpecWriter.FeatureClassName("Checkout").ShouldBe("checkout_slice"); + EmlangSpecWriter.MethodName("RejectsOverlappingWindow").ShouldBe("rejects_overlapping_window"); + } + + [Fact] + public void a_second_run_reports_the_examples_with_no_specification() + { + var model = EmlangImport.ToCurated(EmlangReader.Read(Kitchen), "Kitchen").Model; + var written = + """ + [BobcatFeature("AddItemToOrder")] + public class add_item_to_order(AppFixture app) : KitchenSpec(app) + { + [Fact] + public async Task add_to_an_existing_order() { } + } + """; + + EmlangSpecWriter.MissingSpecs(model, [written]) + .ShouldBe(["AddItemToOrder / add to a closed order", "OrderSummary / nothing ordered yet"]); + } + + [Fact] + public void a_view_slice_whose_example_shares_its_name_still_compiles() + { + // K9CRUSH's VolunteerApplicationsQueue: the view slice and its one example have one name, so + // class and method were both volunteer_applications_queue (CS0542). The class gives way. + var code = EmlangSpecWriterTests.code( + """ + slices: + Queue: + steps: + - e: Volunteer / Application submitted + - v: Applications queue + tests: + ApplicationsQueue: + given: + - e: Volunteer / Application submitted + then: + - v: Applications queue + """); + + code.ShouldContain("[BobcatFeature(\"ApplicationsQueue\")]"); + code.ShouldContain("public class applications_queue_feature(AppFixture app)"); + code.ShouldContain("public async Task applications_queue()"); + } + + [Fact] + public void the_definition_links_exactly_the_identities_the_generated_specs_report() + { + // bobcat#435: the features were the board's chapters and the definition linked the board's + // spelling of each test, so not one generated specification joined its slice. A projected + // test reports {feature}/{method name read as a sentence}; that is what has to be linked. + var (specs, output) = generate(Kitchen); + + var reported = new List(); + string? feature = null; + foreach (var line in specs.AllCode().Split('\n')) + { + if (System.Text.RegularExpressions.Regex.Match(line, @"\[BobcatFeature\(""([^""]*)""\)\]") is { Success: true } f) + feature = f.Groups[1].Value; + else if (System.Text.RegularExpressions.Regex.Match(line, @"public async Task (\w+)\(") is { Success: true } m) + reported.Add($"{feature}/{ProjectedSpecNaming.ScenarioTitleFor(m.Groups[1].Value)}"); + } + + var linked = System.Text.RegularExpressions.Regex.Matches(output.Definition, @"LinksToSpecification\(""([^""]*)""\)") + .Select(x => x.Groups[1].Value); + + reported.ShouldBe( + ["AddItemToOrder/add to an existing order", "AddItemToOrder/add to a closed order", "OrderSummary/nothing ordered yet"], + ignoreOrder: true); + linked.ShouldBe(reported, ignoreOrder: true); + } + + [Fact] + public void no_generated_line_ends_in_whitespace() + { + code(Kitchen).Split('\n').Where(x => x.Length > 0 && char.IsWhiteSpace(x[^1])).ShouldBeEmpty(); + } + + [Fact] + public void a_view_given_is_stored_directly_keyed_by_the_identity_the_test_names() + { + var code = EmlangSpecWriterTests.code( + """ + slices: + Assign driver: + tests: + Driver assigned: + given: + - v: Available drivers + props: + drivers: + - driver-456 + when: + - c: Assign driver + props: + order id: order-123 + then: + - e: Delivery / Driver assigned + props: + order id: order-123 + """); + + unindented(code).ShouldContain(unindented(""" + await GivenReadModel(Specify() + .With(x => x.Id, theOrder) + .With(x => x.Drivers, new List { "driver-456" })); + """)); + code.ShouldNotContain("TODO: the model arranges"); + } + + [Fact] + public void a_view_with_no_identity_anywhere_is_checked_as_the_only_one_of_its_type() + { + var (specs, _) = generate( + """ + slices: + Track available drivers: + steps: + - v: Available drivers + tests: + All available: + then: + - v: Available drivers + props: + drivers: + - driver-456 + """); + + specs.AllCode().ShouldContain("await ThenSingleReadModel(Specify()" + + ".With(x => x.Drivers, new List { \"driver-456\" }));"); + specs.Report.ShouldContain(x => x.Contains("checked as the only AvailableDrivers")); + + // The spec says so as well, so the assumption is never silent (bobcat#450) + specs.AllCode().ShouldContain("// The example names no AvailableDrivers identity, so this checks the only one."); + } + + [Fact] + public void a_refusal_names_the_values_the_model_gives_it() + { + code( + """ + slices: + Register: + tests: + Email taken: + when: + - c: Register + props: + email: joe@example.com + then: + - x: Email already in use + props: + email: joe@example.com + """).ShouldContain("ThenRefusedWith(\"Email already in use\", \"joe@example.com\");"); + } + + [Fact] + public void a_refusal_naming_an_identity_names_its_local() + { + code( + """ + slices: + Close: + tests: + Already closed: + when: + - c: Close order + props: + order id: order-9 + then: + - x: Order already closed + props: + order id: order-9 + """).ShouldContain("ThenRefusedWith(\"Order already closed\", theOrder);"); + } + + [Fact] + public void an_automation_slice_says_what_triggers_it() + { + code( + """ + slices: + Send receipt: + steps: + - e: Order / Payment confirmed + - c: Send receipt + - e: Receipt sent + tests: + Sent: + when: + - c: Send receipt + then: + - e: Receipt sent + """).ShouldContain("// SendReceipt is an automation, triggered by \"Payment confirmed\"\n[BobcatFeature(\"SendReceipt\")]"); + } + + [Fact] + public void a_field_only_an_unattached_example_names_is_still_on_the_stub() + { + // A slice with no steps segments into nothing, so its tests attach to no slice and their + // props never become hints: the specs name the fields anyway, so the stubs must have them + var stubs = generate( + """ + slices: + Assign driver: + tests: + Driver assigned: + when: + - c: Assign driver + props: + order id: order-123 + vehicle: van + seats: 2 + """).Stubs.AllStubs(); + + stubs.ShouldContain("public record AssignDriver(Guid OrderId, string Vehicle, int Seats);"); + } + + [Fact] + public void the_fixture_collection_and_base_class_are_in_test_support_and_nowhere_else() + { + // bobcat#440: starting the real host is an edit to one file that holds nothing else + var (specs, _) = generate(Kitchen); + + var support = specs.Files.Single(x => x.Path == EmlangSpecWriter.TestSupportFile).Content; + support.ShouldContain("namespace Kitchen.Specs;"); + support.ShouldContain("public class AppFixture : IAsyncLifetime"); + support.ShouldContain("public class KitchenCollection : ICollectionFixture;"); + support.ShouldContain("public abstract class KitchenSpec(AppFixture app) : WolverineSpec(app.Host), IAsyncLifetime"); + support.ShouldContain("public async ValueTask InitializeAsync() => await ResetAsync();"); + + // The shape of the real host, for each store, in the placeholder's comments + support.ShouldContain("MartenDaemonModeIsSolo"); + support.ShouldContain("Polecat"); + support.ShouldContain("Fisher"); + + specs.Files.Where(x => x.Path != EmlangSpecWriter.TestSupportFile) + .ShouldAllBe(x => !x.Content.Contains("class AppFixture")); + } + + [Fact] + public void each_slice_has_a_spec_file_in_its_chapters_folder_and_namespace() + { + var board = EmlangReader.Read(Kitchen); + var model = EmlangImport.ToCurated(board, "Kitchen").Model; + var specs = EmlangSpecWriter.Write(board, model, "Kitchen", "Kitchen.Specs"); + + // Slices take the board's chapter, which is the top-level slices: key + var add = specs.Files.Single(x => x.Path == "AddItem/AddItemToOrder.cs"); + add.Content.ShouldContain("namespace Kitchen.Specs.AddItem;"); + add.Content.ShouldContain("[BobcatFeature(\"AddItemToOrder\")]"); + + // bobcat#449: the slice by the type that bears its name, for navigation and the spec manifest + add.Content.ShouldContain("[BobcatSlice(SliceType = typeof(AddItemToOrder))]"); + + // The stubs' namespaces, root and every chapter, come in once, in GlobalUsings.cs, so a + // specification file is just its header, its namespace and its specifications + add.Content.ShouldNotContain("using "); + add.Content.ShouldStartWith("// Generated by `write-specs` (WolverineFx.Bobcat.EventModel) from the model's examples. Nothing regenerates it.\n\nnamespace Kitchen.Specs.AddItem;"); + + var usings = specs.Files.Single(x => x.Path == EmlangSpecWriter.GlobalUsingsFile).Content; + usings.ShouldContain("global using Kitchen;"); + usings.ShouldContain("global using Kitchen.AddItem;"); + usings.ShouldContain("global using Wolverine.Bobcat;"); + usings.ShouldNotContain("System.Collections.Generic"); + specs.Files.Count.ShouldBe(4); + } + + [Fact] + public void test_support_starts_the_applications_own_host_through_alba() + { + var board = EmlangReader.Read(Kitchen); + var model = EmlangImport.ToCurated(board, "Kitchen").Model; + string support(string? store) => EmlangSpecWriter.Write(board, model, "Kitchen", store: store) + .Files.Single(x => x.Path == EmlangSpecWriter.TestSupportFile).Content; + + var plain = support(null); + plain.ShouldContain("public IAlbaHost Host { get; private set; } = null!;"); + plain.ShouldContain("=> Host = await AlbaHost.For(x => x.ConfigureServices(services =>"); + plain.ShouldContain("services.RunWolverineInSoloMode();"); + plain.ShouldContain("opts.ApplicationAssembly = typeof(Program).Assembly"); + plain.ShouldNotContain("TODO"); + plain.ShouldNotContain("placeholder"); + + // No store named: each store's way of running its daemon, as a comment, so it still compiles + plain.ShouldContain("// Marten: services.MartenDaemonModeIsSolo();"); + plain.ShouldNotContain("using Marten;"); + + var marten = support("marten"); + marten.ShouldContain("using Marten;"); + marten.ShouldContain(" services.MartenDaemonModeIsSolo();"); + + support("fisher").ShouldContain("// Fisher's AddAsyncDaemon() already runs in solo mode"); + Should.Throw(() => support("raven")); + } + + private const string Clinic = + """ + slices: + Book visit: + steps: + - c: Book visit + - e: Visit / Visit booked + tests: + Booked: + when: + - c: Book visit + props: + visit id: visit-1 + vet: Dr. Hollis + room: 3 + notes: "" + then: + - e: Visit / Visit booked + props: + visit id: visit-1 + vet: Dr. Hollis + room: 3 + notes: "" + Short: + when: + - c: Book visit + props: + visit id: visit-2 + vet: Dr. Hollis + then: + - e: Visit / Visit booked + props: + visit id: visit-2 + vet: Dr. Hollis + """; + + // Each line without its indentation, so an expectation need not know how deep the generated code sits + internal static string unindented(string text) + => string.Join("\n", text.Replace("\r\n", "\n").Split('\n').Select(x => x.TrimStart())); + + [Fact] + public void an_object_with_more_than_three_members_is_a_property_value_table() + { + var code = unindented(generate(Clinic).Specs.AllCode()); + + code.ShouldContain(unindented("""" + ThenEvents(Specify($$""" + | Property | Value | + | VisitId | {{theVisit}} | + | Vet | Dr. Hollis | + | Room | 3 | + | Notes | EMPTY | + """)); + """")); + + // Three or fewer members stay a With chain + code.ShouldContain("ThenEvents(Specify().With(x => x.VisitId, theVisit).With(x => x.Vet, \"Dr. Hollis\"));"); + } + + [Fact] + public void a_chain_that_would_run_past_120_columns_puts_each_member_on_its_own_line() + { + var code = generate(""" + slices: + Accept assignment: + steps: + - c: Accept home check assignment + - e: Home check / Home check assignment accepted + tests: + Accepted: + when: + - c: Accept home check assignment + props: + home check id: check-1 + volunteer application id: application-1 + then: + - e: Home check / Home check assignment accepted + """).Specs.AllCode(); + + var lines = code.Replace("\r\n", "\n").Split('\n'); + lines.Where(x => x.Length > 120).ShouldBeEmpty(); + + unindented(code).ShouldContain(unindented(""" + await WhenReceived(Specify() + .With(x => x.HomeCheckId, theHomeCheck) + .With(x => x.VolunteerApplicationId, theVolunteerApplication)); + """)); + } + + [Fact] + public void a_value_with_no_faithful_cell_keeps_the_chain_one_member_to_a_line() + { + var code = unindented(generate(Clinic.Replace("vet: Dr. Hollis", "vet: \"Dr. | Hollis\"")).Specs.AllCode()); + + code.ShouldContain(unindented(""" + Specify() + .With(x => x.VisitId, theVisit) + .With(x => x.Vet, "Dr. | Hollis") + """)); + } + + [Fact] + public void the_spec_namespace_defaults_to_the_application_namespace_dot_specs() + { + var (specs, _) = generate(Kitchen); + specs.Files.Single(x => x.Path == EmlangSpecWriter.TestSupportFile).Content.ShouldContain("namespace Kitchen.Specs;"); + } + + [Fact] + public void every_minted_id_is_a_version_7_guid() + { + // A random v4 Guid fragments a stream table's index, and generated code is copied + generate(Kitchen).Specs.AllCode().ShouldNotContain("Guid.NewGuid()"); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/GeneratedText.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/GeneratedText.cs new file mode 100644 index 000000000..4f1f89fd4 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/GeneratedText.cs @@ -0,0 +1,11 @@ +using Bobcat.EventModel; + +namespace Wolverine.Bobcat.EventModel.Tests; + +/// Every generated file as one text, for the assertions that only care what was written, not where. +internal static class GeneratedText +{ + public static string AllCode(this GeneratedSpecs specs) => string.Join("\n", specs.Files.Select(x => x.Content)); + + public static string AllStubs(this CSharpModelWriter.Output output) => string.Join("\n", output.StubFiles.Select(x => x.Content)); +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecMergeTests.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecMergeTests.cs new file mode 100644 index 000000000..48674f858 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecMergeTests.cs @@ -0,0 +1,209 @@ +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; +using Shouldly; + +namespace Wolverine.Bobcat.EventModel.Tests; + +/// +/// The additive re-run for specifications: without --force, an existing spec project gains the board's new +/// examples and nothing already there changes. The specification arm of Bobcat.EventModel's ImportMergeTests, which +/// moved here with and (GH-5082). Each test writes a smaller +/// board, then the full one, into the same directories, exactly as write-specs does. +/// +public sealed class SpecMergeTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("wolverine-spec-merge-").FullName; + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // the temp folder is the OS's to clear + } + } + + private string app => Path.Combine(_root, "Shelter"); + private string specs => Path.Combine(_root, "Shelter.Specs"); + + /// The Shelter board, plus a second event on Confirm appointment and a second example on Cancel appointment. + private static readonly string Full = Boards.Shelter.Replace("\r", "") + .Replace(" - e: Member / Appointment confirmed\n", " - e: Member / Appointment confirmed\n - e: Member / Reminder scheduled\n") + + "\n Cancelled before confirming:\n" + + " given:\n - e: Home check appointment proposed\n" + + " when:\n - c: Cancel appointment\n" + + " then:\n - e: Appointment cancelled\n"; + + private const string AcceptSteps = " - c: Volunteer / Accept home check assignment\n - e: Volunteer / Home check assignment accepted\n"; + + private const string AcceptTest = " Accepted:\n" + + " given:\n - e: Home check requested\n - e: Volunteer approved\n" + + " when:\n - c: Accept home check assignment\n" + + " then:\n - e: Home check assignment accepted\n"; + + /// The full board less the Accept home check assignment slice and its example. + private static string withoutAcceptSlice() => without(Full, AcceptSteps, AcceptTest); + + /// The full board less the second Cancel appointment example. + private static string withoutSecondCancel() => Full[..Full.IndexOf("\n Cancelled before confirming:", StringComparison.Ordinal)] + "\n"; + + private static string without(string yaml, params string[] parts) + { + foreach (var part in parts) + { + var normalized = part.Replace("\r", ""); + yaml.ShouldContain(normalized); + yaml = yaml.Replace(normalized, ""); + } + + return yaml; + } + + /// What write-specs does: the stubs and definitions through Bobcat.EventModel's writer, then the specifications. + private ImportFileWriter import(string yaml, bool force = false) + { + var board = EmlangReader.Read(yaml); + var result = EmlangImport.ToCurated(board, "Shelter", "Shelter"); + var generatedSpecs = EmlangSpecWriter.Write(result.Board ?? board, result.Model, "Shelter", "Shelter.Specs", "marten"); + var generated = CSharpModelWriter.Write(result.Model, "Shelter", generatedSpecs.Additions, perChapter: true); + + var writer = new ImportFileWriter(force); + writer.WriteStubs(app, generated.StubFiles); + writer.WriteDefinitions(app, generated.DefinitionFiles, result.Model); + new SpecFileWriter(writer, force).WriteSpecs(specs, generatedSpecs.Files); + return writer; + } + + private Dictionary snapshot() + => Directory.EnumerateFiles(_root, "*.cs", SearchOption.AllDirectories).ToDictionary(x => x, File.ReadAllText); + + /// What holds beyond , failing unless the only change is one insertion. + private static string insertedOnly(string before, string after) + { + var prefix = 0; + while (prefix < before.Length && before[prefix] == after[prefix]) prefix++; + var inserted = after.Length - before.Length; + inserted.ShouldBeGreaterThan(0); + after[(prefix + inserted)..].ShouldBe(before[prefix..], "everything after the insertion is byte-identical"); + return after.Substring(prefix, inserted); + } + + [Fact] + public void a_new_slices_specification_is_a_new_file_and_simply_written() + { + import(withoutAcceptSlice()); + File.Exists(Path.Combine(specs, "Volunteering", "AcceptHomeCheckAssignment.cs")).ShouldBeFalse(); + + import(Full); + + File.Exists(Path.Combine(specs, "Volunteering", "AcceptHomeCheckAssignment.cs")).ShouldBeTrue(); + } + + [Fact] + public void a_new_example_gets_its_specification_in_the_existing_class_and_the_existing_ones_are_untouched() + { + import(withoutSecondCancel()); + var path = Path.Combine(specs, "Booking", "CancelAppointment.cs"); + // The user edits the existing specification; the merge must not care + var edited = File.ReadAllText(path).Replace("[Fact]", "[Fact] // checked by hand"); + File.WriteAllText(path, edited); + + var writer = import(Full); + var inserted = insertedOnly(edited, File.ReadAllText(path)); + + inserted.ShouldContain("public async Task cancelled_before_confirming()"); + inserted.ShouldNotContain("public async Task cancelled()"); + writer.Callouts.ShouldContain(x => x.Code == ImportCalloutCodes.MergedSpec && x.Slice == "CancelAppointment"); + + var sources = Directory.EnumerateFiles(specs, "*.cs", SearchOption.AllDirectories).Select(File.ReadAllText); + var model = EmlangImport.ToCurated(EmlangReader.Read(Full), "Shelter", "Shelter").Model; + EmlangSpecWriter.MissingSpecs(model, sources).ShouldBeEmpty(); + } + + [Fact] + public void a_specification_class_moved_to_another_file_is_still_where_the_new_example_goes() + { + import(withoutSecondCancel()); + var original = Path.Combine(specs, "Booking", "CancelAppointment.cs"); + var moved = Path.Combine(specs, "Booking", "Cancelling.cs"); + File.Move(original, moved); + + import(Full); + + File.Exists(original).ShouldBeFalse(); + File.ReadAllText(moved).ShouldContain("cancelled_before_confirming"); + } + + [Fact] + public void a_global_using_the_stubs_now_need_is_added_and_the_rest_kept() + { + import(withoutAcceptSlice()); + var path = Path.Combine(specs, EmlangSpecWriter.GlobalUsingsFile); + var trimmed = string.Join("\n", File.ReadAllLines(path).Where(x => !x.Contains("Shelter.Volunteering"))) + "\n"; + File.WriteAllText(path, trimmed); + + var writer = import(Full); + + File.ReadAllText(path).ShouldContain("global using Shelter.Volunteering;"); + writer.Callouts.ShouldContain(x => x.Code == ImportCalloutCodes.MergedUsing); + } + + [Fact] + public void the_test_support_file_is_never_merged_into() + { + import(withoutSecondCancel()); + var path = Path.Combine(specs, EmlangSpecWriter.TestSupportFile); + File.WriteAllText(path, "// mine\n"); + + var writer = import(Full); + + File.ReadAllText(path).ShouldBe("// mine\n"); + writer.Files.ShouldContain(x => x.Path == path && x.Mode == "kept"); + } + + [Fact] + public void a_specification_file_that_does_not_parse_is_skipped_with_a_callout() + { + import(withoutSecondCancel()); + var path = Path.Combine(specs, "Booking", "CancelAppointment.cs"); + File.AppendAllText(path, "public class {\n"); + var before = File.ReadAllText(path); + + var writer = import(Full); + + File.ReadAllText(path).ShouldBe(before); + writer.Callouts.ShouldContain(x => x.Code == ImportCalloutCodes.MergeSkippedUnparseable && x.File == path); + } + + [Fact] + public void writing_twice_changes_nothing_the_second_time() + { + import(withoutAcceptSlice()); + import(Full); + var first = snapshot(); + + var writer = import(Full); + + snapshot().ShouldBe(first); + writer.Callouts.ShouldNotContain(x => x.Code.StartsWith("merged-")); + writer.Files.ShouldAllBe(x => x.Mode == "kept"); + writer.Lines.ShouldBeEmpty(); + } + + [Fact] + public void force_still_overwrites_every_specification_with_the_fresh_one() + { + import(withoutSecondCancel()); + var path = Path.Combine(specs, "Booking", "CancelAppointment.cs"); + File.WriteAllText(path, "// my edits\n"); + + var writer = import(Full, force: true); + + File.ReadAllText(path).ShouldContain("cancelled_before_confirming"); + File.ReadAllText(path).ShouldNotContain("my edits"); + writer.Files.ShouldAllBe(x => x.Mode == "write"); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecProjectWriterTests.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecProjectWriterTests.cs new file mode 100644 index 000000000..948fa0b03 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecProjectWriterTests.cs @@ -0,0 +1,407 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text.Json; +using System.Xml.Linq; +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; +using Shouldly; + +namespace Wolverine.Bobcat.EventModel.Tests; + +/// +/// write-specs (bobcat import-event-model --specs before GH-5082) writes the spec project around the specifications when there is +/// none, never overwrites one, and checks the application without editing it. +/// +public sealed class SpecProjectWriterTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("bobcat-spec-project-").FullName; + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // a build server can still hold a file; the temp folder is the OS's to clear + } + } + + private string write(string relative, string content) + { + var path = Path.Combine(_root, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + private const string AppCsproj = + """ + + + net10.0 + enable + + + + + + """; + + private static readonly string ReadyCsproj = AppCsproj.Replace("", + ""); + + private const string BareProgram = + """ + var builder = WebApplication.CreateBuilder(args); + var app = builder.Build(); + app.Run(); + """; + + private const string ReadyProgram = + """ + var builder = WebApplication.CreateBuilder(args); + var app = builder.Build(); + return await app.RunJasperFxCommands(args); + + public partial class Program; + """; + + private string app(string program = BareProgram, string csproj = AppCsproj) + { + write("Shelter/Program.cs", program); + return write("Shelter/Shelter.csproj", csproj); + } + + private string specs => Path.Combine(_root, "Shelter.Specs"); + + private SpecProjectPlan plan(string? appProject, string? wolverine = null, string? bobcat = null) + => SpecProjectWriter.Plan(new SpecProjectRequest(specs, "Shelter.Specs", "Shelter", appProject, bobcat, wolverine)); + + private static string contentOf(SpecProjectPlan plan, string fileName) + => plan.Files.Single(x => Path.GetFileName(x.Path) == fileName).Content; + + private static IReadOnlyList codes(SpecProjectPlan plan) => plan.Callouts.Select(x => x.Code).ToList(); + + [Fact] + public void a_missing_spec_project_is_written_with_everything_the_specifications_need() + { + var result = plan(app(ReadyProgram, ReadyCsproj)); + + result.Files.Select(x => Path.GetRelativePath(_root, x.Path).Replace('\\', '/')).ShouldBe( + ["Shelter.Specs/Shelter.Specs.csproj", "Shelter.Specs/AssemblyAttributes.cs", "Shelter.Specs/global.json"], + ignoreOrder: true); + + // It has to parse: an XML comment holding the flag's double hyphen did not (MSB4025) + var csproj = XDocument.Parse(contentOf(result, "Shelter.Specs.csproj")); + var packages = csproj.Descendants("PackageReference").ToDictionary(x => x.Attribute("Include")!.Value, x => x.Attribute("Version")?.Value); + packages.Keys.ShouldBe( + ["xunit.v3", "Bobcat", "Bobcat.Xunit", "Bobcat.Generators", "WolverineFx.Bobcat", "Alba", "Microsoft.NET.Test.Sdk", "xunit.runner.visualstudio"], + ignoreOrder: true); + packages["xunit.v3"].ShouldBe("4.0.2"); + packages["Bobcat"].ShouldBe("$(BobcatVersion)"); + packages["WolverineFx.Bobcat"].ShouldBe("$(WolverineFxBobcatVersion)"); + + var generators = csproj.Descendants("PackageReference").Single(x => x.Attribute("Include")!.Value == "Bobcat.Generators"); + generators.Element("PrivateAssets")!.Value.ShouldBe("all"); + generators.Element("IncludeAssets")!.Value.ShouldContain("analyzers"); + + string property(string name) => csproj.Descendants(name).Single().Value; + property("OutputType").ShouldBe("Exe"); + property("TargetFramework").ShouldBe("net10.0"); + property("UseMicrosoftTestingPlatformRunner").ShouldBe("true"); + property("InterceptorsNamespaces").ShouldContain("Bobcat.Generated"); + property("BobcatVersion").ShouldBe(SpecProjectWriter.BobcatVersion); + property("WolverineFxBobcatVersion").ShouldBe(SpecProjectWriter.DefaultWolverineFxBobcatVersion); + + csproj.Descendants("ProjectReference").Single().Attribute("Include")!.Value.ShouldBe(@"..\Shelter\Shelter.csproj"); + + JsonDocument.Parse(contentOf(result, "global.json")).RootElement + .GetProperty("test").GetProperty("runner").GetString().ShouldBe("Microsoft.Testing.Platform"); + + var attributes = contentOf(result, SpecProjectWriter.AssemblyAttributesFile); + attributes.ShouldContain("[assembly: EventModelName(\"Shelter\")]"); + attributes.ShouldContain("Parallelization(Mode = Xunit.Sdk.ParallelMode.None)"); + attributes.ShouldContain("[assembly: WebApplicationFactoryContentRoot(\"Shelter\", \"../../../../Shelter\", \"Shelter.csproj\", \"1\")]"); + + result.Callouts.ShouldBeEmpty(); + } + + [Fact] + public void the_tool_version_is_the_assemblys_own() + { + // The package version, prerelease suffix included: a 1.0.0-alpha.1 tool pins its spec + // project to 1.0.0-alpha.1, because no 1.0.0 exists yet to restore. + var assembly = typeof(SpecProjectWriter).Assembly; + var informational = assembly.GetCustomAttribute()!.InformationalVersion; + + SpecProjectWriter.ToolVersion.ShouldBe(informational.Split('+')[0]); + SpecProjectWriter.ToolVersion.ShouldStartWith(assembly.GetName().Version!.ToString(3)); + + // GH-5082: WolverineFx.Bobcat ships from the same build, so a generated spec project pins this version of it + SpecProjectWriter.DefaultWolverineFxBobcatVersion.ShouldBe(SpecProjectWriter.ToolVersion); + } + + [Fact] + public void the_bobcat_version_is_the_one_this_package_was_built_against() + { + // GH-5082: stamped from the repository's central Bobcat version, which is what WolverineFx.Bobcat references + var stamped = typeof(SpecProjectWriter).Assembly.GetCustomAttributes() + .Single(x => x.Key == "BobcatVersion").Value; + + stamped.ShouldNotBeNullOrEmpty(); + SpecProjectWriter.BobcatVersion.ShouldBe(stamped); + SpecProjectWriter.BobcatVersion.ShouldBe(typeof(global::Bobcat.Monitoring.MonitorEvent).Assembly + .GetCustomAttribute()!.InformationalVersion.Split('+')[0]); + } + + [Fact] + public void the_versions_can_be_overridden() + { + var csproj = XDocument.Parse(contentOf(plan(app(ReadyProgram), wolverine: "6.50.0-local.1", bobcat: "0.61.0-local.2"), "Shelter.Specs.csproj")); + + csproj.Descendants("WolverineFxBobcatVersion").Single().Value.ShouldBe("6.50.0-local.1"); + csproj.Descendants("BobcatVersion").Single().Value.ShouldBe("0.61.0-local.2"); + + // A property the build already has (-p: or a Directory.Build.props) still wins + csproj.Descendants("WolverineFxBobcatVersion").Single().Attribute("Condition")!.Value + .ShouldBe("'$(WolverineFxBobcatVersion)' == ''"); + } + + [Fact] + public void the_target_framework_follows_the_application() + { + var multi = AppCsproj.Replace("net10.0", "net9.0;net10.0"); + var csproj = contentOf(plan(app(ReadyProgram, multi)), "Shelter.Specs.csproj"); + XDocument.Parse(csproj).Descendants("TargetFramework").Single().Value.ShouldBe("net10.0"); + + var net9 = contentOf(plan(app(ReadyProgram, AppCsproj.Replace("net10.0", "net9.0"))), "Shelter.Specs.csproj"); + XDocument.Parse(net9).Descendants("TargetFramework").Single().Value.ShouldBe("net9.0"); + } + + [Fact] + public void an_existing_spec_project_is_never_overwritten_and_is_checked_instead() + { + var existing = write("Shelter.Specs/Shelter.Specs.csproj", + """ + + + + + + + """); + write("global.json", SpecProjectWriter.GlobalJson); + + var result = plan(app(ReadyProgram)); + + result.Files.ShouldBeEmpty(); + result.Kept.Select(x => x.Path).ShouldContain(existing); + + var kept = result.Callouts.Single(x => x.Code == ImportCalloutCodes.SpecProjectFileKept); + kept.File.ShouldBe(existing); + kept.Slice.ShouldBe(""); + + result.Callouts.Where(x => x.Code == ImportCalloutCodes.SpecProjectMissingReference).Select(x => x.Fix).ShouldBe( + [ + "", + "", + "" + ]); + result.Callouts.Single(x => x.Code == ImportCalloutCodes.SpecProjectMissingEventModelName).Fix + .ShouldBe("[assembly: EventModelName(\"Shelter\")]"); + } + + [Fact] + public void an_existing_assembly_attributes_file_is_kept() + { + var attributes = write("Shelter.Specs/AssemblyAttributes.cs", "// mine"); + + var result = plan(app(ReadyProgram)); + + result.Files.ShouldNotContain(x => x.Path == attributes); + result.Callouts.Single(x => x.Code == ImportCalloutCodes.SpecProjectFileKept).File.ShouldBe(attributes); + } + + [Fact] + public void global_json_goes_at_the_solution_root() + { + write("Shelter.slnx", ""); + + var result = plan(app(ReadyProgram)); + + result.Files.ShouldContain(x => x.Path == Path.Combine(_root, "global.json")); + var callout = result.Callouts.Single(x => x.Code == ImportCalloutCodes.SpecProjectNotInSolution); + callout.Fix.ShouldBe($"dotnet sln \"{Path.Combine(_root, "Shelter.slnx")}\" add \"{Path.Combine(specs, "Shelter.Specs.csproj")}\""); + } + + [Fact] + public void an_existing_global_json_is_checked_never_shadowed() + { + var existing = write("global.json", """{ "sdk": { "version": "10.0.100" } }"""); + + var result = plan(app(ReadyProgram)); + + result.Files.ShouldNotContain(x => Path.GetFileName(x.Path) == "global.json"); + var callout = result.Callouts.Single(x => x.Code == ImportCalloutCodes.GlobalJsonMissingTestRunner); + callout.File.ShouldBe(existing); + callout.Fix.ShouldBe("\"test\": { \"runner\": \"Microsoft.Testing.Platform\" }"); + } + + [Fact] + public void central_package_management_takes_the_versions_in_directory_packages_props() + { + var props = write("Directory.Packages.props", + """ + + true + + + """); + + var result = plan(app(ReadyProgram), wolverine: "6.50.0-local.1"); + + var csproj = XDocument.Parse(contentOf(result, "Shelter.Specs.csproj")); + csproj.Descendants("PackageReference").ShouldAllBe(x => x.Attribute("Version") == null); + + var callout = result.Callouts.Single(x => x.Code == ImportCalloutCodes.CentralPackageVersions); + callout.File.ShouldBe(props); + callout.Fix!.ShouldContain(""); + callout.Fix!.ShouldNotContain("\"xunit.v3\""); + } + + [Fact] + public void what_the_application_is_missing_is_called_out_with_the_exact_fix() + { + var project = app(); + + var result = plan(project); + + codes(result).ShouldBe( + [ + ImportCalloutCodes.AppProgramNotPublic, + ImportCalloutCodes.AppMissingRunJasperFxCommands, + ImportCalloutCodes.AppMissingRuntimeCompilation + ]); + result.Callouts[0].Fix.ShouldBe("public partial class Program;"); + result.Callouts[0].File.ShouldBe(Path.Combine(_root, "Shelter", "Program.cs")); + result.Callouts[1].Fix.ShouldBe("return await app.RunJasperFxCommands(args);"); + result.Callouts[2].Fix.ShouldBe($"dotnet add \"{project}\" package WolverineFx.RuntimeCompilation --version 6.50.0-local.1"); + + // and nothing in the application was touched + File.ReadAllText(Path.Combine(_root, "Shelter", "Program.cs")).ShouldBe(BareProgram); + File.ReadAllText(project).ShouldBe(AppCsproj); + } + + [Fact] + public void an_application_with_everything_has_no_callouts() + { + SpecProjectWriter.CheckApp(app(ReadyProgram, ReadyCsproj)).ShouldBeEmpty(); + } + + [Fact] + public void a_public_program_inside_the_spec_project_does_not_count_for_the_application() + { + write("Shelter/Tests/Program.cs", "public partial class Program;"); + + SpecProjectWriter.CheckApp(app(), Path.Combine(_root, "Shelter", "Tests")).Select(x => x.Code) + .ShouldContain(ImportCalloutCodes.AppProgramNotPublic); + } + + [Fact] + public void no_application_project_is_called_out_with_the_flag_that_names_one() + { + var result = plan(null); + + var callout = result.Callouts.Single(x => x.Code == ImportCalloutCodes.AppProjectNotFound); + callout.Override.ShouldBe("--app-project "); + contentOf(result, "Shelter.Specs.csproj").ShouldContain("TODO: the application project"); + XDocument.Parse(contentOf(result, "Shelter.Specs.csproj")).Descendants("ProjectReference").ShouldBeEmpty(); + } + + [Fact] + public void the_application_project_is_the_one_csproj_in_out() + { + var project = app(); + SpecProjectWriter.FindAppProject(Path.Combine(_root, "Shelter")).ShouldBe(project); + + write("Shelter/Other.csproj", ""); + SpecProjectWriter.FindAppProject(Path.Combine(_root, "Shelter")).ShouldBeNull(); + } + + [Fact] + public void the_json_carries_a_project_callouts_file_and_fix() + { + var result = plan(app()); + + using var json = JsonDocument.Parse(ImportJson.Write("Shelter", result.Callouts, [])); + var first = json.RootElement.GetProperty("callouts")[0]; + first.GetProperty("code").GetString().ShouldBe(ImportCalloutCodes.AppProgramNotPublic); + first.GetProperty("file").GetString().ShouldEndWith("Program.cs"); + first.GetProperty("fix").GetString().ShouldBe("public partial class Program;"); + } + + /// + /// The whole path: import a board, write the stubs, the specifications and the spec project, and + /// dotnet build it. Needs NuGet and a WolverineFx.Bobcat that pairs with this Bobcat, so it runs + /// only when BOBCAT_IMPORT_BUILD_WOLVERINE names that version (and BOBCAT_IMPORT_BUILD_FEED + /// a local feed holding it, when it is not on nuget.org). + /// + [Fact] + public async Task the_generated_spec_project_builds() + { + var wolverine = Environment.GetEnvironmentVariable("BOBCAT_IMPORT_BUILD_WOLVERINE"); + Assert.SkipWhen(string.IsNullOrEmpty(wolverine), "Set BOBCAT_IMPORT_BUILD_WOLVERINE to a WolverineFx version to build the generated spec project"); + + var feed = Environment.GetEnvironmentVariable("BOBCAT_IMPORT_BUILD_FEED"); + if (feed is { Length: > 0 }) + { + write("NuGet.config", + $""" + + + + + + + + + + + + + + """); + } + + var project = app(ReadyProgram.Replace("var builder", "using JasperFx;\nusing Wolverine;\nvar builder") + .Replace("var app = builder.Build();", "builder.Host.UseWolverine(opts => opts.ServiceName = \"Shelter\");\nvar app = builder.Build();"), + AppCsproj.Replace("6.50.0-local.1", wolverine!) + .Replace("", $"")); + + var board = EmlangReader.Read(Boards.Shelter); + var imported = EmlangImport.ToCurated(board, "Shelter", "Shelter", [], []); + var generatedSpecs = EmlangSpecWriter.Write(imported.Board ?? board, imported.Model, "Shelter", "Shelter.Specs"); + var model = CSharpModelWriter.Write(imported.Model, "Shelter", generatedSpecs.Additions, perChapter: true); + + foreach (var file in model.StubFiles.Concat(model.DefinitionFiles)) write("Shelter/" + file.Path, file.Content); + foreach (var file in generatedSpecs.Files) write("Shelter.Specs/" + file.Path, file.Content); + + var result = plan(project, wolverine); + result.Callouts.ShouldBeEmpty(); + foreach (var file in result.Files) write(Path.GetRelativePath(_root, file.Path), file.Content); + + var build = Process.Start(new ProcessStartInfo("dotnet", "build") + { + WorkingDirectory = specs, + RedirectStandardOutput = true, + RedirectStandardError = true, + Environment = { ["MSBUILDDISABLENODEREUSE"] = "1" } + })!; + var output = await build.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken); + await build.WaitForExitAsync(TestContext.Current.CancellationToken); + + build.ExitCode.ShouldBe(0, output); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecsBesideTheImportTests.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecsBesideTheImportTests.cs new file mode 100644 index 000000000..fd8c795db --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/SpecsBesideTheImportTests.cs @@ -0,0 +1,225 @@ +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; +using Shouldly; + +namespace Wolverine.Bobcat.EventModel.Tests; + +/// +/// The specification half of Bobcat.EventModel's import tests: AggregateInferenceTests, EventModelersJsonReaderTests, +/// StreamStartIdentityTests and StrongTypedIdentityTests each asserted on the stubs AND the specifications one import +/// wrote. JasperFx/bobcat#755 kept the stub half there and the specification half moved here with the spec writer +/// (GH-5082), unchanged but for where the board comes from. +/// +public class SpecsBesideTheImportTests +{ + private static string code(GeneratedSpecs specs) => string.Join("\n", specs.Files.Select(x => x.Content)); + + private static string stubs(CSharpModelWriter.Output output) => string.Join("\n", output.StubFiles.Select(x => x.Content)); + + // ---------------------------------------------------------------- AggregateInferenceTests + + [Fact] + public void a_command_drawing_on_two_streams_carries_each_ones_identity_and_the_spec_sets_both() + { + var board = EmlangReader.Read(Boards.Shelter); + var model = EmlangImport.ToCurated(board, "Shelter", "Shelter").Model; + var specs = EmlangSpecWriter.Write(board, model, "Shelter"); + var written = stubs(CSharpModelWriter.Write(model, "Shelter", specs.Additions)); + var all = code(specs); + + written.ShouldContain("public record AcceptHomeCheckAssignment(Guid HomeCheckId, Guid VolunteerApplicationId);"); + all.ShouldContain("await GivenEvents(theHomeCheck, Specify());"); + all.ShouldContain("await GivenEventsOn(theVolunteerApplication, Specify());"); + EmlangSpecWriterTests.unindented(all).ShouldContain(EmlangSpecWriterTests.unindented(""" + await WhenReceived(Specify() + .With(x => x.HomeCheckId, theHomeCheck) + .With(x => x.VolunteerApplicationId, theVolunteerApplication)); + """)); + } + + [Fact] + public void a_command_drawing_on_two_streams_checks_each_stream_by_its_own_key() + { + // wolverine#4865: ThenEvents could only speak for one of them + var board = EmlangReader.Read(Boards.Shelter); + var model = EmlangImport.ToCurated(board, "Shelter", "Shelter").Model; + var all = code(EmlangSpecWriter.Write(board, model, "Shelter")); + + all.ShouldContain("ThenEventsOn(theHomeCheck, Specify());"); + all.ShouldContain("ThenNoEventsOn(theVolunteerApplication);"); + all.ShouldNotContain("ThenEvents(Specify());"); + } + + [Fact] + public void a_command_that_starts_a_stream_is_checked_as_one_paragraph() + { + // wolverine#4865: the handler assigns the stream's id, so the spec reads it back from the act + var board = EmlangReader.Read(Boards.Shelter); + var model = EmlangImport.ToCurated(board, "Shelter", "Shelter").Model; + var all = code(EmlangSpecWriter.Write(board, model, "Shelter")); + + all.ShouldContain("await ThenStreamIsStartedWithEvents(Specify());"); + } + + [Fact] + public void the_specs_arrange_on_the_typed_aggregate_and_address_it_by_id() + { + var board = EmlangReader.Read(Boards.Shelter); + var all = code(EmlangSpecWriter.Write(board, EmlangImport.ToCurated(board, "Shelter", "Shelter").Model, "Shelter")); + + all.ShouldContain("await GivenEvents(theAppointment, Specify());"); + all.ShouldContain("await WhenReceived(Specify().With(x => x.Id, theAppointment));"); + all.ShouldNotContain("await GivenEvents(the"); + } + + [Fact] + public void a_type_only_an_example_names_lands_in_the_chapter_of_the_slice_whose_example_names_it() + { + var board = EmlangReader.Read(Boards.Shelter); + var model = EmlangImport.ToCurated(board, "Shelter", "Shelter").Model; + var specs = EmlangSpecWriter.Write(board, model, "Shelter"); + var output = CSharpModelWriter.Write(model, "Shelter", specs.Additions); + + output.Layout.FileOf("ApplicationReviewed").ShouldBe("Features/Volunteering/ApplicationReviewed.cs"); + output.Layout.NamespaceOf("ApplicationReviewed").ShouldBe("Shelter.Volunteering"); + } + + // ---------------------------------------------------------------- EventModelersJsonReaderTests + + [Fact] + public void the_generator_writes_specs_from_a_json_export() + { + var board = EventModelersJsonReader.Read(Boards.Config); + var model = EmlangImport.ToCurated(board, "Shop").Model; + + var specs = EmlangSpecWriter.Write(board, model, "Shop"); + + specs.Specs.ShouldBe(2); + code(specs).ShouldContain("ThenRefusedWith(\"Quantity over limit\");"); + code(specs).ShouldContain("await WhenReceived(Specify().With(x => x.CartId, theCart).With(x => x.Quantity, 2));"); + } + + // ---------------------------------------------------------------- StreamStartIdentityTests + + private static (string Stubs, string Specs) generate(EmlangBoard board, string ns = "Shelter") + { + var model = EmlangImport.ToCurated(board, ns, ns).Model; + var specs = EmlangSpecWriter.Write(board, model, ns); + return (stubs(CSharpModelWriter.Write(model, ns, specs.Additions)), code(specs)); + } + + [Fact] + public void the_spec_reads_a_started_streams_id_from_the_act_when_the_command_cannot_carry_it() + { + var (written, specs) = generate(EventModelersJsonReader.Read(Boards.MintedIdentity), "Applications"); + var all = EmlangSpecWriterTests.unindented(specs); + + written.ShouldContain("public record SubmitApplication(string Areas);"); + + // No value the spec declares can reach a handler that mints the id, so none is declared up front + specs.ShouldNotContain("GivenNoEventsFor"); + specs.ShouldNotContain("Guid.CreateVersion7()"); + all.ShouldContain(EmlangSpecWriterTests.unindented( + """ + await WhenReceived(Specify().With(x => x.Areas, "dogs")); + + var theApplication = TheStartedStream(); + """)); + all.ShouldContain("ThenStreamIsStartedWithEvents(Specify()"); + all.ShouldContain(".With(x => x.ApplicationId, theApplication)"); + } + + [Fact] + public void a_started_streams_id_nothing_expects_is_not_read_back() + { + var (_, specs) = generate(EventModelersJsonReader.Read(Boards.MintedIdentity.Replace( + "{ \"name\": \"applicationId\", \"type\": \"UUID\", \"idAttribute\": true },", "")), "Applications"); + + specs.ShouldNotContain("TheStartedStream"); + specs.ShouldContain("ThenStreamIsStartedWithEvents("); + } + + [Fact] + public void a_command_that_carries_the_streams_identity_still_arranges_the_empty_stream() + { + // The model names the identity on the command, so the spec knows the id before the act + var (_, specs) = generate(EventModelersJsonReader.Read(Boards.MintedIdentity.Replace( + "{ \"name\": \"areas\", \"type\": \"String\" } ] } ],\n \"events\"", + "{ \"name\": \"applicationId\", \"type\": \"UUID\", \"idAttribute\": true }, { \"name\": \"areas\", \"type\": \"String\" } ] } ],\n \"events\"")), + "Applications"); + + specs.ShouldContain("await GivenNoEventsFor(theApplication);"); + specs.ShouldNotContain("TheStartedStream"); + } + + [Fact] + public void a_string_keyed_stream_is_started_with_the_id_its_command_carries_and_the_spec_declares_it() + { + var (written, specs) = generate(EventModelersJsonReader.Read(Boards.StreamStartNaturalKey), "Customers"); + + // A natural key cannot be minted, so the command carries it, as a string + written.ShouldContain("public record RegisterCustomer(string Id, string Name);"); + specs.ShouldContain("await GivenNoEventsFor(theCustomer);"); + specs.ShouldContain(".With(x => x.Id, theCustomer)"); + specs.ShouldNotContain("TheStartedStream"); + } + + // ---------------------------------------------------------------- StrongTypedIdentityTests + + private static (CSharpModelWriter.Output Stubs, GeneratedSpecs Specs) typedImport(string yaml = Boards.Ordering, bool typed = true) + { + var board = EmlangReader.Read(yaml); + var imported = EmlangImport.ToCurated(board, "Shop").Model; + var ids = typed ? StrongTypedIds.For(imported, CSharpModelWriter.StubNames(imported)) : StrongTypedIds.None; + var specs = EmlangSpecWriter.Write(board, imported, "Shop", "Shop.Specs", null, ids); + + return (CSharpModelWriter.Write(imported, "Shop", specs.Additions, perChapter: true, ids), specs); + } + + [Fact] + public void a_generated_specification_declares_the_identity_as_the_typed_value() + { + var all = code(typedImport().Specs); + + // The half that would otherwise not compile: a Guid local handed to an OrderId member + all.ShouldContain("var theOrder = new OrderId(Guid.CreateVersion7());"); + all.ShouldContain(".With(x => x.OrderId, theOrder)"); + } + + [Fact] + public void without_the_flag_the_specification_declares_a_guid() + { + code(typedImport(typed: false).Specs).ShouldContain("var theOrder = Guid.CreateVersion7();"); + } + + [Fact] + public void a_typed_natural_key_is_declared_as_the_identity_type() + { + var board = EventModelersJsonReader.Read(Boards.StrongTypedNaturalKey); + var imported = EmlangImport.ToCurated(board, "Customers", "Customers").Model; + var ids = StrongTypedIds.For(imported, CSharpModelWriter.StubNames(imported)); + var specs = EmlangSpecWriter.Write(board, imported, "Customers", "Customers.Specs", null, ids); + var generated = CSharpModelWriter.Write(imported, "Customers", specs.Additions, perChapter: true, ids); + + // The command carries the key, now as the identity type, and the spec declares it as one + stubs(generated).ShouldContain("public record RegisterCustomer(CustomerId Id, string Name);"); + code(specs).ShouldContain("""var theCustomer = new CustomerId("customer-1");"""); + } + + [Fact] + public void a_command_against_two_streams_types_both_and_the_spec_declares_both() + { + var board = EmlangReader.Read(Boards.Shelter); + var imported = EmlangImport.ToCurated(board, "Shelter", "Shelter").Model; + var ids = StrongTypedIds.For(imported, CSharpModelWriter.StubNames(imported)); + var specs = EmlangSpecWriter.Write(board, imported, "Shelter", "Shelter.Specs", null, ids); + var generated = CSharpModelWriter.Write(imported, "Shelter", specs.Additions, perChapter: true, ids); + + stubs(generated).ShouldContain( + "public record AcceptHomeCheckAssignment(HomeCheckId HomeCheckId, VolunteerApplicationId VolunteerApplicationId);"); + + var all = code(specs); + all.ShouldContain("var theHomeCheck = new HomeCheckId(Guid.CreateVersion7());"); + all.ShouldContain(".With(x => x.VolunteerApplicationId, theVolunteerApplication)"); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Usings.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Usings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Wolverine.Bobcat.EventModel.Tests.csproj b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Wolverine.Bobcat.EventModel.Tests.csproj new file mode 100644 index 000000000..e2c9f4a0f --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/Wolverine.Bobcat.EventModel.Tests.csproj @@ -0,0 +1,36 @@ + + + + + Exe + false + true + net9.0;net10.0 + false + 13 + + $(NoWarn);NU1202;NETSDK1005;CS0419;xUnit1051;CS8981 + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/WriteSpecsCommandTests.cs b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/WriteSpecsCommandTests.cs new file mode 100644 index 000000000..037ee19fc --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel.Tests/WriteSpecsCommandTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using Bobcat.EventModel; +using Shouldly; + +namespace Wolverine.Bobcat.EventModel.Tests; + +/// +/// GH-5082: write-specs end to end, the command that took over bobcat import-event-model --specs: the board in, +/// the stubs merged, the specifications and the spec project written, and a second run additive. +/// +public sealed class WriteSpecsCommandTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("wolverine-write-specs-").FullName; + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // the temp folder is the OS's to clear + } + } + + private string app => Path.Combine(_root, "Shelter"); + private string specs => Path.Combine(_root, "Shelter.Specs"); + + private async Task run(params string[] extra) + { + var board = Path.Combine(_root, "shelter.yaml"); + if (!File.Exists(board)) await File.WriteAllTextAsync(board, Boards.Shelter); + + Directory.CreateDirectory(app); + var project = Path.Combine(app, "Shelter.csproj"); + if (!File.Exists(project)) await File.WriteAllTextAsync(project, "\n"); + + var input = new WriteSpecsInput + { + FilePath = board, + ModelFlag = "Shelter", + OutFlag = app, + StoreFlag = "marten", + JsonFlag = extra.Contains("--json") + }; + + return await new WriteSpecsCommand().Execute(input); + } + + [Fact] + public async Task writes_the_stubs_the_specifications_and_the_spec_project() + { + (await run()).ShouldBeTrue(); + + File.Exists(Path.Combine(app, "Features", "Booking", "ConfirmAppointment.cs")).ShouldBeTrue(); + File.Exists(Path.Combine(specs, EmlangSpecWriter.TestSupportFile)).ShouldBeTrue(); + File.ReadAllText(Path.Combine(specs, "Booking", "ConfirmAppointment.cs")).ShouldContain("[BobcatFeature(\"ConfirmAppointment\")]"); + + var csproj = File.ReadAllText(Path.Combine(specs, "Shelter.Specs.csproj")); + csproj.ShouldContain($"{SpecProjectWriter.BobcatVersion}"); + csproj.ShouldContain($"{SpecProjectWriter.ToolVersion}"); + csproj.ShouldContain(@""); + } + + [Fact] + public async Task a_second_run_changes_nothing() + { + (await run()).ShouldBeTrue(); + var first = Directory.EnumerateFiles(_root, "*.*", SearchOption.AllDirectories).ToDictionary(x => x, File.ReadAllText); + + (await run()).ShouldBeTrue(); + + Directory.EnumerateFiles(_root, "*.*", SearchOption.AllDirectories).ToDictionary(x => x, File.ReadAllText).ShouldBe(first); + } + + [Fact] + public async Task an_input_that_is_not_a_board_is_refused() + { + var path = Path.Combine(_root, "notes.txt"); + await File.WriteAllTextAsync(path, "just some notes"); + + (await new WriteSpecsCommand().Execute(new WriteSpecsInput { FilePath = path, OutFlag = app })).ShouldBeFalse(); + Directory.Exists(specs).ShouldBeFalse(); + } + + [Fact] + public async Task a_missing_file_is_refused() + { + (await new WriteSpecsCommand().Execute(new WriteSpecsInput { FilePath = Path.Combine(_root, "nope.json") })).ShouldBeFalse(); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel/AssemblyAttributes.cs b/src/Bobcat/Wolverine.Bobcat.EventModel/AssemblyAttributes.cs new file mode 100644 index 000000000..8020fad64 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel/AssemblyAttributes.cs @@ -0,0 +1,5 @@ +using JasperFx; + +// write-specs is discovered by JasperFx's command line, like Wolverine's own scaffold, in any application that +// references this package (GH-5082) +[assembly: JasperFxAssembly] diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel/EmlangSpecWriter.cs b/src/Bobcat/Wolverine.Bobcat.EventModel/EmlangSpecWriter.cs new file mode 100644 index 000000000..8e30bb0f6 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel/EmlangSpecWriter.cs @@ -0,0 +1,1296 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using JasperFx.CodeGeneration; + +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; + +namespace Wolverine.Bobcat.EventModel; + +/// What produced. +/// +/// The specification files, each relative to the spec project (bobcat#440): TestSupport.cs +/// with the fixture, the collection and the base spec class, then one file per slice under a folder +/// per chapter. +/// +/// How many feature classes it holds: one per slice with an example. +/// How many specifications: one per test with something to say. +/// What the stubs need beyond the model's own for the specs to compile. +/// Every guess and gap, one line each, for the person to go and fix. +public sealed record GeneratedSpecs( + IReadOnlyList Files, + int Features, + int Specs, + CSharpModelWriter.StubAdditions Additions, + IReadOnlyList Report); + +/// +/// Writes an emlang model's examples as WolverineFx.Bobcat specifications (bobcat#423): one +/// [BobcatFeature] class per slice, one [Fact] per test, each value the example +/// gives as a partial object naming exactly those members. +/// +/// +/// +/// A specification's identity is the one the model links (bobcat#435). The feature is the +/// SLICE the import attached the test to, not the board chapter it was written under, and the +/// scenario is , the method name read back as a sentence, which is what +/// a projected test reports. EmlangImport names each scenario the same way, so the +/// definition's LinksToSpecification, the pushed descriptor and a run's +/// scenario_finished all carry one string. Before this, the features were chapters and +/// the definition linked the board's spelling, so not one generated specification joined its slice. +/// +/// +/// The code targets WolverineFx.Bobcat's WolverineSpec, which this package does not +/// reference: it is text, and the project it lands in references WolverineFx.Bobcat. +/// +/// +/// Types come from the stubs. Every member's type is what +/// gives the stub, so the generated specs and the stubs +/// written beside them always agree. An identity the model does not type is a Guid, declared as a +/// local (var theOrder = Guid.CreateVersion7();) so the rendered spec names it theOrder. +/// An identity the model never names is minted the same way, and the act addresses it through the +/// command's conventional Id (bobcat#438). +/// +/// +/// It is additive. The command writes the file only when it does not exist; run again, it adds a +/// specification for each new example to the existing class (), and +/// reports the model's tests that still have none. +/// +/// +public static class EmlangSpecWriter +{ + /// The file holding the fixture, the collection and the base specification class. + public const string TestSupportFile = "TestSupport.cs"; + + /// The spec project's global usings, so each specification file holds only its specification. + public const string GlobalUsingsFile = "GlobalUsings.cs"; + + /// The board the model was imported from: its tests are the examples. + /// The imported model. + /// The application's root namespace, where the stubs and the definition live. + /// + /// The spec project's root namespace, which should be the spec project's name; each chapter + /// folder adds a segment. Defaults to {ns}.Specs. + /// + /// + /// The application's event store: marten, polecat or fisher. It decides how the + /// generated host runs the store's async daemon; when null the host names each store's way in a comment. + /// + /// + /// + /// The strong-typed identifiers the stubs are written with (bobcat#512/#433), so a specification + /// declares an identity as the typed value — var theOrder = new OrderId(Guid.CreateVersion7()); + /// — and every member that takes it compiles. , or null, keeps the + /// Guid it always was. + /// + public static GeneratedSpecs Write(EmlangBoard board, ImportedEventModel model, string ns, string? specsNamespace, + string? store, StrongTypedIds? identities) + => write(board, model, ns, specsNamespace, store, identities ?? StrongTypedIds.None); + + /// + public static GeneratedSpecs Write(EmlangBoard board, ImportedEventModel model, string ns, string? specsNamespace = null, + string? store = null) + => write(board, model, ns, specsNamespace, store, StrongTypedIds.None); + + private static GeneratedSpecs write(EmlangBoard board, ImportedEventModel model, string ns, string? specsNamespace, + string? store, StrongTypedIds identities) + { + var context = new Context(model, identities); + context.Declare(board); + var modelName = CSharpModelWriter.Identifiers.Sanitize(model.Model); + if (modelName.Length == 0) modelName = "Imported"; + var root = specsNamespace is { Length: > 0 } ? specsNamespace : ns + ".Specs"; + + // Bodies first: what they name decides the stubs, and so the namespaces to bring in + var bodies = new List<(string? Chapter, string FileName, string Body)>(); + var features = 0; + var specs = 0; + var classNames = new HashSet(StringComparer.Ordinal); + + foreach (var slice in model.Slices) + { + var scenarios = slice.Specifications?.Scenarios.Where(x => x.Source is not null).ToList() ?? []; + if (scenarios.Count == 0) + { + context.Report.Add($"slice '{slice.Name}': no tests, so no specifications."); + continue; + } + + // A slice and one of its examples often share a name (a view slice and the test of that + // view), and C# refuses a member named for its enclosing type (CS0542). The class name is + // not part of the identity, [BobcatFeature] is, so the class gives way, never the method. + var methodNames = scenarios.Select(x => MethodName(x.Source!.Name)).ToHashSet(StringComparer.Ordinal); + var classCandidate = FeatureClassName(slice.Name); + if (methodNames.Contains(classCandidate)) classCandidate += "_feature"; + var className = unique(classNames, classCandidate); + features++; + + using var writer = new SourceWriter(); + if (slice.Pattern == "Automation" && slice.Trigger?.Label is { Length: > 0 } trigger) + { + // The examples exercise the command; what triggers it is wiring they never reach + var said = $"// {slice.Name} is an automation, triggered by \"{comment(trigger)}\""; + if (said.Length <= MaxLineLength) + { + writer.WriteLine(said); + } + else + { + writer.WriteLine($"// {slice.Name} is an automation, triggered by"); + writer.WriteLine($"// \"{comment(trigger)}\""); + } + } + + writer.WriteLine($"[BobcatFeature({quote(slice.Name)})]"); + writer.WriteLine(sliceBinding(slice, context)); + writer.Write($"BLOCK:public class {className}(AppFixture app) : {modelName}Spec(app)"); + + var first = true; + foreach (var scenario in scenarios) + { + if (!first) writer.BlankLine(); + first = false; + + // The import already deduplicated the slice's scenarios by title, and a title + // round-trips to exactly one method name, so no method here needs a suffix + writer.WriteLine("[Fact]"); + writer.Write($"BLOCK:public async Task {MethodName(scenario.Source!.Name)}()"); + foreach (var line in new TestWriter(context, slice.Name, scenario.SourceChapter!, scenario.Source!).Lines()) + { + if (line.Length == 0) writer.BlankLine(); + else writer.WriteLine(line); + } + writer.FinishBlock(); + specs++; + } + + writer.FinishBlock(); + + var fileName = CSharpModelWriter.Identifiers.Sanitize(slice.Name); + bodies.Add((ModelLayout.ChapterFolder(slice.Chapter), fileName.Length == 0 ? className : fileName, writer.Code())); + } + + // A test the import attached to no slice still gets its specification, under its chapter as + // before: what it arranges and asserts is still worth writing, and its examples are still + // fields the stubs need. It binds to nothing on the model, and the report says so. + var attached = model.Slices + .SelectMany(x => x.Specifications?.Scenarios ?? []) + .Select(x => x.Source) + .OfType() + .ToHashSet(ReferenceEqualityComparer.Instance); + + foreach (var chapter in board.Chapters) + { + var orphans = chapter.Tests.Where(x => !attached.Contains(x)).ToList(); + if (orphans.Count == 0) continue; + + var className = unique(classNames, FeatureClassName(chapter.Name)); + features++; + + using var writer = new SourceWriter(); + writer.WriteLine($"// The model attaches these to no slice, so they bind to nothing on the event model"); + writer.WriteLine($"[BobcatFeature({quote(chapter.Name)})]"); + writer.Write($"BLOCK:public class {className}(AppFixture app) : {modelName}Spec(app)"); + + var methods = new HashSet(StringComparer.Ordinal); + var first = true; + foreach (var test in orphans) + { + if (!first) writer.BlankLine(); + first = false; + + context.Report.Add($"⚠ chapter '{chapter.Name}', test '{test.Name}': attached to no slice, so its specification binds to nothing on the model."); + writer.WriteLine("[Fact]"); + writer.Write($"BLOCK:public async Task {unique(methods, MethodName(test.Name))}()"); + foreach (var line in new TestWriter(context, chapter.Name, chapter, test).Lines()) + { + if (line.Length == 0) writer.BlankLine(); + else writer.WriteLine(line); + } + writer.FinishBlock(); + specs++; + } + + writer.FinishBlock(); + var folder = ModelLayout.ChapterFolder(chapter.Name); + bodies.Add((folder, (folder ?? "Model") + "Unattached", writer.Code())); + } + + var additions = new CSharpModelWriter.StubAdditions(context.Streams.ToList(), context.Documents.ToList(), context.Elements.ToList(), + context.ExtraFields.ToDictionary(x => x.Key, x => (IReadOnlyList)x.Value)); + + // Every namespace the stubs land in: a spec names types from any chapter, and an unused + // using costs nothing where a missing one does not compile + var appNamespaces = CSharpModelWriter.Layout(model, ns, additions).Namespaces; + + var files = new List + { + new(GlobalUsingsFile, writeGlobalUsings(appNamespaces)), + new(TestSupportFile, writeTestSupport(root, modelName, store)) + }; + var paths = new HashSet(StringComparer.OrdinalIgnoreCase) { GlobalUsingsFile, TestSupportFile }; + foreach (var (chapter, fileName, body) in bodies) + { + var path = uniquePath(paths, chapter is null ? fileName : $"{chapter}/{fileName}"); + files.Add(new GeneratedFile(path, writeSpecFile(ModelLayout.NamespaceFor(root, chapter), body))); + } + + return new GeneratedSpecs(files, features, specs, additions, context.Report); + } + + /// + /// bobcat#449: the spec's slice, by the type that bears its name where one does — the command, or + /// a view's read model — so the IDE navigates from the spec to it and a rename keeps the two + /// together; by name otherwise. SliceType means exactly SliceName = type.Name (#324). + /// + private static string sliceBinding(CuratedSlice slice, Context context) + { + var name = CSharpModelWriter.Identifiers.Sanitize(slice.Name); + var named = new[] { slice.Command }.Concat(slice.ReadModels) + .OfType() + .Select(CSharpModelWriter.Identifiers.Sanitize) + .FirstOrDefault(x => x.Length > 0 && x == name); + + if (named is not null) + { + if (!context.Elements.Contains(named)) context.Elements.Add(named); + return $"[BobcatSlice(SliceType = typeof({named}))]"; + } + + return $"[BobcatSlice(SliceName = {quote(slice.Name)})]"; + } + + private static string uniquePath(HashSet taken, string stem) + { + var candidate = stem + ".cs"; + for (var i = 2; !taken.Add(candidate); i++) candidate = $"{stem}{i}.cs"; + return candidate; + } + + // The usings live in GlobalUsings.cs, so a specification file is just its specifications + private static string writeSpecFile(string ns, string body) + { + var builder = new StringBuilder(); + builder.Append("// Generated by `write-specs` (WolverineFx.Bobcat.EventModel) from the model's examples. Nothing regenerates it.\n"); + builder.Append('\n'); + builder.Append($"namespace {ns};\n"); + builder.Append('\n'); + builder.Append(body.TrimEnd('\n', '\r')); + builder.Append('\n'); + return builder.ToString(); + } + + /// + /// The model's scenarios with no specification among , as + /// slice / scenario: what a second run reports instead of writing over the first. + /// + public static IReadOnlyList MissingSpecs(ImportedEventModel model, IEnumerable sources) + { + var written = new HashSet<(string Feature, string Method)>(); + foreach (var source in sources) + { + string? feature = null; + foreach (var line in source.Split('\n')) + { + if (FeatureAttribute.Match(line) is { Success: true } attribute) + { + feature = Regex.Unescape(attribute.Groups[1].Value); + } + else if (feature is not null && TestMethod.Match(line) is { Success: true } method) + { + written.Add((feature, method.Groups[1].Value)); + } + } + } + + return model.Slices + .SelectMany(slice => (slice.Specifications?.Scenarios ?? []) + .Where(x => x.Source is { } source && !written.Contains((slice.Name, MethodName(source.Name)))) + .Select(x => $"{slice.Name} / {x.Name}")) + .ToList(); + } + + private static readonly Regex FeatureAttribute = new(@"\[BobcatFeature\(""((?:[^""\\]|\\.)*)""\)\]", RegexOptions.Compiled); + private static readonly Regex TestMethod = new(@"public\s+async\s+Task\s+(\w+)\s*\(", RegexOptions.Compiled); + + /// A slice's class name: snake case, as Bobcat feature classes are written. + public static string FeatureClassName(string slice) + { + var name = snake(slice); + + // A single lowercase word is reserved for future keywords (CS8981) + return name.Contains('_') ? name : name + "_slice"; + } + + /// A test's method name: snake case. + public static string MethodName(string test) => snake(test); + + /// + /// The scenario title a test's generated specification reports: its + /// read back as a sentence, the way the projected lane titles every test (bobcat#435). + /// EmlangImport names the model's scenario this, so the two always agree. + /// + public static string ScenarioTitle(string test) => ProjectedSpecNaming.ScenarioTitleFor(MethodName(test)); + + /// + /// GlobalUsings.cs: every namespace a specification names, once for the whole project, and + /// what the generated specifications are. + /// + private static string writeGlobalUsings(IReadOnlyList appNamespaces) + { + var builder = new StringBuilder(); + builder.Append("// Generated by `write-specs` (WolverineFx.Bobcat.EventModel). Nothing regenerates this file.\n"); + builder.Append("//\n"); + builder.Append("// The specifications hold one test per example in the model, each value a partial object that\n"); + builder.Append("// names only what the example names. Run the import again and it adds a specification for each\n"); + builder.Append("// new example, without touching the ones here.\n"); + builder.Append('\n'); + foreach (var ns in new[] { "System", "System.Threading.Tasks", "Bobcat", "Wolverine.Bobcat", "Xunit" }.Concat(appNamespaces)) + { + builder.Append($"global using {ns};\n"); + } + + return builder.ToString(); + } + + /// + /// TestSupport.cs (bobcat#440): the fixture, the collection and the base specification + /// class, kept out of the specification files so the host is configured in one file that holds + /// nothing else. + /// + /// + /// The fixture is the application's own host, started through Alba as it is configured in + /// production, with Wolverine in solo mode and external transports off. Nothing in the + /// specifications depends on the store: WolverineSpec works against the application's + /// IEventStore, and ResetAsync resets every event store the host registers. Only how + /// the store's async daemon runs differs, which decides. + /// + private static string writeTestSupport(string ns, string modelName, string? store) + { + var daemon = store?.Trim().ToLowerInvariant() switch + { + "marten" => new[] { "// Marten's async daemon, in solo mode so projections run in this process", "services.MartenDaemonModeIsSolo();" }, + "polecat" => ["// Polecat's async daemon runs in solo mode where the application calls AddAsyncDaemon(DaemonMode.Solo)"], + "fisher" => ["// Fisher's AddAsyncDaemon() already runs in solo mode"], + null or "" => + [ + "// For async projections, run the store's daemon in solo mode, in this process:", + "// Marten: services.MartenDaemonModeIsSolo();", + "// Polecat: AddAsyncDaemon(DaemonMode.Solo) where the application configures Polecat", + "// Fisher: AddAsyncDaemon() already runs in solo mode" + ], + var other => throw new ArgumentOutOfRangeException(nameof(store), other, "The store is marten, polecat or fisher") + }; + + using var writer = new SourceWriter(); + writer.WriteLine("// Generated by `write-specs` (WolverineFx.Bobcat.EventModel). Nothing regenerates this file."); + writer.BlankLine(); + writer.WriteLine("using Alba;"); + if (store?.Trim().ToLowerInvariant() == "marten") writer.WriteLine("using Marten;"); + writer.WriteLine("using Wolverine;"); + writer.BlankLine(); + writer.WriteLine($"namespace {ns};"); + writer.BlankLine(); + writer.WriteLine("/// "); + writer.WriteLine("/// The application under test: its own host, configured as it is in production and started once for"); + writer.WriteLine("/// every specification. Alba needs the application's Program to be public, so the application"); + writer.WriteLine("/// declares public partial class Program;."); + writer.WriteLine("/// "); + writer.WriteLine("/// "); + writer.WriteLine("/// The specifications use the application's own database and schema, so the data a failing"); + writer.WriteLine("/// specification leaves behind is where a developer would look for it."); + writer.WriteLine("/// "); + writer.Write("BLOCK:public class AppFixture : IAsyncLifetime"); + writer.WriteLine("public IAlbaHost Host { get; private set; } = null!;"); + writer.BlankLine(); + writer.WriteLine("public async ValueTask InitializeAsync()"); + writer.WriteLine(" => Host = await AlbaHost.For(x => x.ConfigureServices(services =>"); + writer.WriteLine(" {"); + writer.WriteLine(" services.DisableAllExternalWolverineTransports();"); + writer.WriteLine(" services.RunWolverineInSoloMode();"); + writer.BlankLine(); + foreach (var line in daemon) writer.WriteLine(" " + line); + writer.BlankLine(); + writer.WriteLine(" // Under xUnit v3 the test project is the entry assembly, which Wolverine would otherwise"); + writer.WriteLine(" // adopt as the application assembly, and then find no handlers or endpoints in"); + writer.WriteLine(" services.ConfigureWolverine(opts => opts.ApplicationAssembly = typeof(Program).Assembly);"); + writer.WriteLine(" }));"); + writer.BlankLine(); + writer.WriteLine("public async ValueTask DisposeAsync() => await Host.DisposeAsync();"); + writer.FinishBlock(); + writer.BlankLine(); + writer.WriteLine("/// "); + writer.WriteLine("/// Every specification shares the one host and runs one at a time, because each resets the event store."); + writer.WriteLine("/// "); + writer.WriteLine($"[CollectionDefinition({quote(modelName)})]"); + writer.WriteLine($"public class {modelName}Collection : ICollectionFixture;"); + writer.BlankLine(); + writer.WriteLine("/// "); + writer.WriteLine("/// The base of every specification: the Given/When/Then vocabulary, and a reset before each test."); + writer.WriteLine("/// ResetAsync resets every event store the host registers (Marten, Polecat or Fisher), so each"); + writer.WriteLine("/// specification starts from an empty store. Nothing is torn down afterwards, on purpose."); + writer.WriteLine("/// "); + writer.WriteLine($"[Collection({quote(modelName)})]"); + writer.Write($"BLOCK:public abstract class {modelName}Spec(AppFixture app) : WolverineSpec(app.Host), IAsyncLifetime"); + writer.WriteLine("public async ValueTask InitializeAsync() => await ResetAsync();"); + writer.BlankLine(); + writer.WriteLine("public ValueTask DisposeAsync() => ValueTask.CompletedTask;"); + writer.FinishBlock(); + return writer.Code(); + } + + private sealed class Context(ImportedEventModel model, StrongTypedIds identities) + { + /// The strong-typed identifiers the stubs use, so a spec's locals take the same types. + public StrongTypedIds Ids { get; } = identities; + + /// + /// The primitive a member's type IS: a strong-typed identity's inner Guid or + /// string, else the type itself. Every decision about how to render or declare a value + /// asks this, so OrderId takes exactly the path Guid takes. + /// + public string Primitive(string type) => Ids.Inner(type) ?? type; + + private readonly Dictionary Identities, List Props)> _declared = new(StringComparer.Ordinal); + + /// + /// What the model's own steps say about each element: its stream, and which props are its + /// identity. A scenario item often repeats neither: an eventmodelers.ai export names the + /// aggregate on the element, not on every example of it (bobcat#433 discussion, case C). + /// + public void Declare(EmlangBoard board) + { + foreach (var step in board.Chapters.SelectMany(x => x.Steps)) + { + if (step.Kind is not (EmlangElementKind.Command or EmlangElementKind.Event or EmlangElementKind.View)) continue; + + var typeName = TypeName(step.Label); + // bobcat#439: only a stream the model DECLARES. An emlang swimlane names who acts + // (Admin / Volunteer approved), not where the event is stored, and reading it as a + // stream spread one application's events over Admin, Volunteer and ShelterStaff. + var stream = step.Stream is { Length: > 0 } declared ? declared : null; + + if (!_declared.TryGetValue(typeName, out var known)) + { + _declared[typeName] = (stream, [.. step.Identities], [.. step.Props.Keys]); + continue; + } + + if (known.Stream is null && stream is not null) _declared[typeName] = known with { Stream = stream }; + foreach (var identity in step.Identities.Where(x => !known.Identities.Contains(x))) known.Identities.Add(identity); + foreach (var prop in step.Props.Keys.Where(x => !known.Props.Contains(x))) known.Props.Add(prop); + } + } + + /// + /// The stream an element is stored on: the aggregate the import declared or inferred for an + /// event (bobcat#444), else what the model's steps declare, else null. + /// + public string? StreamOf(string typeName) + => Model.EventStreams.GetValueOrDefault(typeName) ?? _declared.GetValueOrDefault(typeName).Stream; + + /// + /// The props that are an element's identity: the ones the model marks (idAttribute), + /// or, when it marks none, the prop named for its stream (orderId on an Order + /// event) or the generic aggregateId some models use. + /// + public IReadOnlyList IdentitiesOf(string typeName) + { + if (!_declared.TryGetValue(typeName, out var known)) return []; + if (known.Identities.Count > 0) return known.Identities; + + var stream = StreamOf(typeName) is { Length: > 0 } declared ? TypeName(declared) : null; + return known.Props + .Where(x => EmlangImport.PascalName(x) is var name + && (name == "AggregateId" || (stream is not null && name == stream + "Id"))) + .Take(1) + .ToList(); + } + + private readonly Dictionary> _fields = new(StringComparer.Ordinal); + + public ImportedEventModel Model { get; } = model; + public List Report { get; } = []; + public SortedSet Streams { get; } = new(StringComparer.Ordinal); + public SortedSet Documents { get; } = new(StringComparer.Ordinal); + + /// Every command, event and view a specification names, in the order first named. + public List Elements { get; } = []; + + /// + /// Fields an example names that the model's hints never reached, such as a test that attaches + /// to no slice: added to the stub so the specification that names them compiles. + /// + public Dictionary> ExtraFields { get; } = new(StringComparer.Ordinal); + + /// The member a prop names on a stub, and its type, exactly as the stub declares it. + /// The example's value, which types a field the stub does not have yet. + public (string Name, string Type) Member(string typeName, string prop, object? sample = null) + { + var name = CSharpModelWriter.Identifiers.Sanitize(EmlangImport.PascalName(prop)); + if (name == typeName) name += "Value"; + + if (!_fields.TryGetValue(typeName, out var fields)) + { + fields = CSharpModelWriter.FieldsOf(Model, typeName, Ids); + _fields[typeName] = fields; + } + + if (fields.FirstOrDefault(x => x.Name == name) is { } declared) return (name, declared.Type); + + if (!ExtraFields.TryGetValue(typeName, out var extra)) ExtraFields[typeName] = extra = []; + if (extra.FirstOrDefault(x => x.Name == name) is { } added) return (name, added.Type); + + var type = name.EndsWith("Id", StringComparison.Ordinal) ? Ids.IdOfMember(typeName, name) ?? "Guid" + : sample is List ? CuratedFieldTypes.StringList + : CuratedFieldTypes.TryInfer(EmlangReader.Text(sample), out var inferred) ? inferred + : "string"; + extra.Add(new CSharpModelWriter.StubField(name, type)); + return (name, type); + } + } + + /// One test's body. A class rather than a method because a test accumulates state as it goes: its identities. + private sealed class TestWriter(Context context, string slice, EmlangChapter chapter, EmlangTest test) + { + private readonly List _lines = []; + + // An identity's example value ("order-123") → the local that stands for it + private readonly Dictionary _ids = new(StringComparer.Ordinal); + private readonly Dictionary _names = new(StringComparer.Ordinal); + + private static readonly EmlangElementKind[] Elements = + [EmlangElementKind.Command, EmlangElementKind.Event, EmlangElementKind.View]; + + private string where => $"slice '{slice}', test '{test.Name}'"; + + public IEnumerable Lines() + { + declareIdentities(); + if (_ids.Count > 0 || _mintedLocals.Count > 0) _lines.Add(""); + + var arranged = given(); + act(); + assert(arranged); + + // A minted identity whose only element wrote no partial is referenced nowhere: drop it + foreach (var variable in _mintedLocals) + { + var declaration = $"var {variable} = "; + var used = _lines.Any(x => !x.StartsWith(declaration, StringComparison.Ordinal) + && System.Text.RegularExpressions.Regex.IsMatch(x, $@"\b{variable}\b")); + if (!used) _lines.RemoveAll(x => x.StartsWith(declaration, StringComparison.Ordinal)); + } + + if (_lines.Count > 0 && _lines[0].Length == 0) _lines.RemoveAt(0); + while (_lines.Count > 0 && _lines[^1].Length == 0) _lines.RemoveAt(_lines.Count - 1); + + // A partial written as a table spans several lines + return _lines.SelectMany(x => x.Split('\n')); + } + + /// One local per identity the example names: typed ones (Guids) first, then string ids. + private void declareIdentities() + { + var references = test.Given.Concat(test.When).Concat(test.Then).Where(x => Elements.Contains(x.Kind)).ToList(); + + // A strong-typed identity takes the pass of what it wraps (bobcat#512): OrderId is a Guid + // identity that happens to say so in the type system, not a third kind of value + foreach (var wanted in new[] { "Guid", "string" }) + { + foreach (var reference in references) + { + var typeName = TypeName(reference.Label); + foreach (var (prop, value) in reference.Props) + { + var (member, type) = context.Member(typeName, prop, reference.Values.GetValueOrDefault(prop)); + if (context.Primitive(type) != wanted || value.Length == 0 || _ids.ContainsKey(value)) continue; + if (type == "string" && !member.EndsWith("Id", StringComparison.Ordinal)) continue; + + declare(member, value, type); + } + } + } + + // An example that names a stream's identity is that stream's local, for every element + // on the stream that leaves its own identity out + foreach (var reference in references) + { + var typeName = TypeName(reference.Label); + if (streamName(reference) is not { } stream || _streams.ContainsKey(stream)) continue; + + foreach (var (prop, value) in reference.Props) + { + if (!identityProp(typeName, stream, prop)) continue; + if (_ids.TryGetValue(value, out var id)) _streams[stream] = id.Variable; + } + } + + // The model marks an identity but the example gives it no value (bobcat#433 discussion, + // case C): mint one local per stream, and set it on every element that names none + foreach (var reference in references) + { + var typeName = TypeName(reference.Label); + var stream = streamName(reference); + + foreach (var prop in context.IdentitiesOf(typeName)) + { + if (reference.Props.TryGetValue(prop, out var given) && given.Length > 0) continue; + + var (member, type) = context.Member(typeName, prop); + var owner = stream ?? (member.EndsWith("Id", StringComparison.Ordinal) && member.Length > 2 ? member[..^2] : member); + if (!_streams.TryGetValue(owner, out var variable)) + { + variable = mint(owner, type); + _streams[owner] = variable; + } + + if (!_minted.TryGetValue(reference, out var fields)) _minted[reference] = fields = []; + if (fields.All(x => x.Member != member)) fields.Add((member, variable)); + } + } + } + + // A stream (aggregate) -> the local that stands for its identity in this test + private readonly Dictionary _streams = new(StringComparer.Ordinal); + + // An identity the model marks but the example leaves out, set on the element's partial + private readonly Dictionary> _minted = new(ReferenceEqualityComparer.Instance); + + /// + /// The stream (aggregate type) of a reference's element: what the model declares, else what + /// the import inferred from the examples (bobcat#444). Null only when neither names one, and + /// then the events go on a stream with no aggregate type, which Marten, Polecat and Fisher all + /// allow (bobcat#439). Never the swimlane, which is an actor. + /// + private string? streamName(EmlangRef reference) + => context.StreamOf(TypeName(reference.Label)) is { Length: > 0 } declared ? TypeName(declared) : null; + + private bool identityProp(string typeName, string stream, string prop) + { + if (context.IdentitiesOf(typeName).Contains(prop)) return true; + if (EmlangImport.PascalName(prop) == "AggregateId") return true; + return CSharpModelWriter.Identifiers.Sanitize(EmlangImport.PascalName(prop)) == stream + "Id"; + } + + private readonly List _mintedLocals = []; + + /// Whether a reference has anything to put in a partial: example values, or a minted identity. + private bool hasValues(EmlangRef reference) => reference.Props.Count > 0 || _minted.ContainsKey(reference); + + /// A local for an identity the example never gives a value. + private string mint(string owner, string type) + { + var name = "the" + owner; + var count = _names[name] = _names.GetValueOrDefault(name) + 1; + var variable = count == 1 ? name : name + count; + _mintedLocals.Add(variable); + + _lines.Add(context.Primitive(type) == "Guid" + ? $"var {variable} = {wrap(type, "Guid.CreateVersion7()")};" + : $"var {variable} = {wrap(type, quote(EmlangImport.PascalName(owner).ToLowerInvariant() + "-1"))};"); + return variable; + } + + /// + /// An identity value in its declared type: new OrderId(...) for a strong-typed identifier + /// (bobcat#512), the expression itself for a primitive one. + /// + private string wrap(string type, string expression) + => context.Ids.Contains(type) ? $"new {type}({expression})" : expression; + + private void declare(string member, string value, string type) + { + var stem = member.EndsWith("Id", StringComparison.Ordinal) && member.Length > 2 ? member[..^2] : member; + var name = "the" + stem; + var count = _names[name] = _names.GetValueOrDefault(name) + 1; + var variable = count == 1 ? name : name + count; + + _ids[value] = (variable, type); + _lines.Add(context.Primitive(type) == "Guid" + ? $"var {variable} = {wrap(type, "Guid.CreateVersion7()")}; // {quote(value)} in the model" + : $"var {variable} = {wrap(type, quote(value))};"); + } + + /// Whether any event was arranged. + private bool given() + { + var groups = new List<(string? Stream, string Key, List Events)>(); + var views = new List(); + + foreach (var reference in test.Given) + { + switch (reference.Kind) + { + case EmlangElementKind.Event: + var stream = streamOf(reference); + var key = keyOf(stream, reference); + var group = groups.FirstOrDefault(x => x.Stream == stream && x.Key == key); + if (group.Events is null) + { + group = (stream, key, []); + groups.Add(group); + } + + group.Events.Add(partial(reference)); + break; + + case EmlangElementKind.View: + // Stored directly: GivenReadModel bypasses the projection, as the model does + views.Add(reference); + break; + } + } + + for (var i = 0; i < groups.Count; i++) + { + var (stream, key, events) = groups[i]; + if (i == 0) _actKey = key; + if (stream is not null) _streamKeys.TryAdd(stream, key); + + // No declared aggregate: a stream with no aggregate type (bobcat#439) + if (stream is null) + { + call($"await GivenEvents({key}, ", events, ");"); + continue; + } + + var verb = i == 0 ? "GivenEvents" : "GivenEventsOn"; + call($"await {verb}<{stream}>({key}, ", events, ");"); + } + + foreach (var view in views) + { + var typeName = document(view.Label); + var open = $"await GivenReadModel<{typeName}>("; + _lines.Add(open + documentPartial(typeName, view).Render(Body + open.Length) + ");"); + } + + if (groups.Count == 0 && test.When.Any(x => x.Kind == EmlangElementKind.Command)) + { + // The act runs against a stream that starts empty: name it, from the first event expected + var expected = test.Then.FirstOrDefault(x => x.Kind == EmlangElementKind.Event && streamName(x) is not null); + if (expected is not null && streamName(expected) is { } stream + && (identityOf(stream, expected) ?? _streams.GetValueOrDefault(stream)) is { } key) + { + context.Streams.Add(stream); + if (!carriesIdentity(stream, key)) + { + // bobcat#453: the handler mints the new stream's id, so no value declared here can + // reach it. The id is read back from the act instead, after it runs. + _startedKey = (stream, key); + } + else + { + _actKey = key; + _lines.Add($"await GivenNoEventsFor<{stream}>({key});"); + } + } + } + + if (_lines.Count > 0 && _lines[^1].Length > 0) _lines.Add(""); + return groups.Count > 0; + } + + private void act() + { + var commands = test.When.Where(x => x.Kind == EmlangElementKind.Command).ToList(); + const string open = "await WhenReceived("; + foreach (var command in commands) _lines.Add(open + addressed(command).Render(Body + open.Length) + ");"); + if (commands.Count > 0) _lines.Add(""); + } + + // The key of the stream the givens were arranged on, which the act addresses + private string? _actKey; + + // A stream the act starts under an id its handler mints, and the local that stands for it (bobcat#453) + private (string Stream, string Key)? _startedKey; + + /// + /// Whether the act's command can carry the stream's identity to its handler (bobcat#453): it has + /// an Id (the conventional one, or one an example names), the stream's {Aggregate}Id + /// or AggregateId, an identity the model marks, or an example value that is the key. + /// A command that only starts a Guid-keyed stream has none of these, because its handler mints the id. + /// + private bool carriesIdentity(string stream, string key) + { + foreach (var command in test.When.Where(x => x.Kind == EmlangElementKind.Command)) + { + var typeName = TypeName(command.Label); + if (CSharpModelWriter.GetsDefaultId(context.Model, typeName)) return true; + if (context.IdentitiesOf(typeName).Count > 0) return true; + if (CSharpModelWriter.FieldsOf(context.Model, typeName).Any(f => f.Name is "Id" or "AggregateId" || f.Name == stream + "Id")) return true; + if (command.Props.Values.Any(x => _ids.TryGetValue(x, out var id) && id.Variable == key)) return true; + } + + return false; + } + + /// + /// The local that stood for a started stream's id becomes the id the act's handler assigned + /// (bobcat#453), declared after the act: var theApplication = TheStartedStream<Application>();. + /// Dropped again, like any minted local, when no expectation uses it. + /// + private void readStartedStreamFromAct() + { + if (_startedKey is not { } started) return; + + var declaration = $"var {started.Key} = "; + _lines.RemoveAll(x => x.StartsWith(declaration, StringComparison.Ordinal)); + if (!_mintedLocals.Contains(started.Key)) _mintedLocals.Add(started.Key); + _lines.Add($"{declaration}TheStartedStream<{started.Stream}>();"); + } + + // Each arranged stream (aggregate) -> its key, for a command addressing several (bobcat#444) + private readonly Dictionary _streamKeys = new(StringComparer.Ordinal); + + /// + /// The command's partial, addressed to the arranged stream by its conventional Id when + /// the model gives the command no identity of its own (bobcat#438). Without it the command's + /// Guids were all random, so it could never reach the stream the assertion reads. + /// + private PartialSpec addressed(EmlangRef command) + { + var built = partial(command); + var typeName = built.TypeName; + var owner = context.Model.Slices.FirstOrDefault(s => CSharpModelWriter.Identifiers.Sanitize(s.Command ?? "") == typeName); + + // Several streams: each addressed by its own {Aggregate}Id (bobcat#444) + var streamIds = CSharpModelWriter.StreamIdFieldsOf(context.Model, typeName); + if (streamIds.Count > 0 && owner is not null) + { + foreach (var aggregate in owner.Aggregates) + { + var member = CSharpModelWriter.Identifiers.Sanitize(aggregate) + "Id"; + if (!_streamKeys.TryGetValue(TypeName(aggregate), out var key)) continue; + if (built.Has(member)) continue; + + context.Member(typeName, member); + built.AddVariable(member, key); + } + + return built; + } + + if (_actKey is null || built.Has("Id")) return built; + if (!CSharpModelWriter.GetsDefaultId(context.Model, typeName)) return built; + + // A string-keyed stream's command carries a string Id (bobcat#453), and a strong-typed one + // carries the identity type (bobcat#512) — both of which Member would otherwise type Guid + if (CSharpModelWriter.DefaultIdType(context.Model, typeName, context.Ids) is var idType && idType != "Guid") + { + if (!context.ExtraFields.TryGetValue(typeName, out var extra)) context.ExtraFields[typeName] = extra = []; + if (extra.All(x => x.Name != "Id")) extra.Add(new CSharpModelWriter.StubField("Id", idType)); + } + + // The stream the command decides against, when the givens arranged more than one + var actKey = owner?.Aggregates.Count == 1 && _streamKeys.TryGetValue(TypeName(owner.Aggregates[0]), out var own) + ? own + : _actKey; + + context.Member(typeName, "id"); + built.AddVariable("Id", actKey); + return built; + } + + private void assert(bool arranged) + { + readStartedStreamFromAct(); + var acted = test.When.Any(x => x.Kind == EmlangElementKind.Command); + var events = test.Then.Where(x => x.Kind == EmlangElementKind.Event).ToList(); + var refusals = test.Then.Where(x => x.Kind == EmlangElementKind.Error).ToList(); + var views = test.Then.Where(x => x.Kind == EmlangElementKind.View).ToList(); + + foreach (var refusal in refusals) + { + // A refusal's props are the values its message names: EmailAlreadyInUse { email } + var named = refusal.Props.Values + .Where(x => x.Length > 0) + .Select(x => _ids.TryGetValue(x, out var id) ? id.Variable : quote(x)); + _lines.Add($"ThenRefusedWith({string.Join(", ", new[] { quote(refusal.Label) }.Concat(named))});"); + } + + // A command deciding against several arranged streams: each stream's events checked on it by its + // own key, and a stream the act leaves alone said so (bobcat#444, wolverine#4865) + var owner = context.Model.Slices.FirstOrDefault(s => + test.When.Any(w => w.Kind == EmlangElementKind.Command && CSharpModelWriter.Identifiers.Sanitize(s.Command ?? "") == partial(w).TypeName)); + var decidedAgainst = owner?.Aggregates.Select(TypeName).Where(_streamKeys.ContainsKey).ToList() ?? []; + + if (events.Count > 0 && decidedAgainst.Count > 1) + { + foreach (var stream in decidedAgainst) + { + var on = events.Where(x => streamName(x) == stream).Select(partial).ToList(); + if (on.Count == 0) _lines.Add($"ThenNoEventsOn<{stream}>({_streamKeys[stream]});"); + else call($"ThenEventsOn<{stream}>({_streamKeys[stream]}, ", on, ");"); + } + + // An event the model puts on no stream the command decides against is still checked + var elsewhere = events.Where(x => streamName(x) is not { } stream || !decidedAgainst.Contains(stream)).Select(partial).ToList(); + if (elsewhere.Count > 0) call("ThenEmitted(", elsewhere, ");"); + } + else if (events.Count > 0 && !arranged && _actKey is null + && events.Select(streamName).Distinct().ToList() is [{ } started]) + { + // The act starts the stream, with an id its handler assigns: one paragraph, "a VolunteerApplication + // stream is started" and "VolunteerApplicationSubmitted is emitted on it" (wolverine#4865) + // Asynchronous -- it reads the stream back -- so awaited, or the spec ends before it checks + call($"await ThenStreamIsStartedWithEvents<{started}>(", events.Select(partial).ToList(), ");"); + } + else if (events.Count > 0) + { + call("ThenEvents(", events.Select(partial).ToList(), ");"); + } + else if (acted && views.Count == 0) + { + // A refusal appends nothing, and neither does an act the model expects nothing from + _lines.Add("ThenNoEvents();"); + } + + foreach (var view in views) + { + var typeName = document(view.Label); + + // No identity in the example: a singleton view, the only one of its type + var key = documentKey(typeName, view); + if (key is null) + { + context.Report.Add($"{where}: the {typeName} view names no identity, so it is checked as the only {typeName}."); + + // Said in the spec too, never assumed silently (bobcat#450): a view keyed per stream + // needs ThenReadModel(id, ...) instead + _lines.Add($"// The example names no {typeName} identity, so this checks the only one. If there can be"); + _lines.Add($"// several, name the one this example means: await ThenReadModel<{typeName}>(id, ...);"); + _lines.Add(!hasValues(view) + ? $"await ThenSingleReadModel<{typeName}>();" + : rendered($"await ThenSingleReadModel<{typeName}>(", partial(view), ");")); + continue; + } + + _lines.Add(!hasValues(view) + ? $"await ThenReadModel<{typeName}>({key});" + : rendered($"await ThenReadModel<{typeName}>({key}, ", partial(view), ");")); + } + + if (!acted && events.Count == 0 && refusals.Count == 0 && views.Count == 0) + { + // A test of a view that names nothing to expect: the read model does not exist + var view = chapter.Steps.FirstOrDefault(x => x.Kind == EmlangElementKind.View); + if (view is null) + { + _lines.Add("// TODO: the model expects nothing here, and the slice has no view to look for"); + context.Report.Add($"⚠ {where}: expects nothing, and its slice has no view."); + return; + } + + var typeName = document(view.Label); + _lines.Add(anyIdentity() is { } key + ? $"await ThenNoReadModel<{typeName}>({key});" + : $"await ThenNoReadModel<{typeName}>();"); + } + } + + // The column a test body's statements start at: two levels in, class then method + private const int Body = 8; + + private static string rendered(string open, PartialSpec argument, string close) + => open + argument.Render(Body + open.Length) + close; + + private void call(string open, IReadOnlyList arguments, string close) + { + if (arguments.Count == 1) + { + _lines.Add(rendered(open, arguments[0], close)); + return; + } + + _lines.Add(open.TrimEnd().TrimEnd(',')); + if (!_lines[^1].EndsWith('(')) _lines[^1] += ","; + for (var i = 0; i < arguments.Count; i++) + { + var argument = arguments[i].Render(Body + 4); + _lines.Add(" " + argument.Replace("\n", "\n ") + (i < arguments.Count - 1 ? "," : close)); + } + } + + private PartialSpec partial(EmlangRef reference) + { + var typeName = TypeName(reference.Label); + if (!context.Elements.Contains(typeName)) context.Elements.Add(typeName); + var spec = new PartialSpec(typeName); + + foreach (var (prop, text) in reference.Props) + { + var raw = reference.Values.GetValueOrDefault(prop); + var (member, type) = context.Member(typeName, prop, raw); + if (text.Length > 0 && _ids.TryGetValue(text, out var id) && (id.Type == type || type == "string")) + { + spec.Add(member, id.Type == type ? id.Variable : $"{id.Variable}.ToString()", "{{" + id.Variable + "}}"); + continue; + } + + spec.Add(member, Literal(type, text, raw, context.Ids), Cell(type, text, raw, context.Ids)); + } + + foreach (var (member, variable) in _minted.GetValueOrDefault(reference) ?? []) + { + spec.AddVariable(member, variable); + } + + return spec; + } + + private string? streamOf(EmlangRef reference) + { + var stream = streamName(reference); + if (stream is not null) context.Streams.Add(stream); + return stream; + } + + /// The stream's identity in an event: {Stream}Id, or else the first identity it names. + private string? identityOf(string? stream, EmlangRef reference) + { + string? fallback = null; + foreach (var (prop, text) in reference.Props) + { + var (member, _) = context.Member(TypeName(reference.Label), prop, reference.Values.GetValueOrDefault(prop)); + if (!_ids.TryGetValue(text, out var id)) continue; + + if (stream is not null && member == stream + "Id") return id.Variable; + fallback ??= member.EndsWith("Id", StringComparison.Ordinal) ? id.Variable : null; + } + + return fallback; + } + + /// + /// The stream's key in this test: the identity the event names, else the one already minted + /// for the stream, else a fresh one minted here (bobcat#438). The act addresses that same + /// local through the command's Id, so an identity the model never names is no longer + /// a TODO: it is simply a value only the test needs to know. + /// + private string keyOf(string? stream, EmlangRef reference) + { + if (identityOf(stream, reference) is { } key) return key; + + var owner = stream ?? "Stream"; + if (_streams.TryGetValue(owner, out var minted)) return minted; + + var variable = mint(owner, context.Ids.IdOf(owner) ?? "Guid"); + _streams[owner] = variable; + return variable; + } + + /// A read model's type, recorded as a document the stubs give an id. + private string document(string label) + { + var typeName = TypeName(label); + context.Documents.Add(typeName); + if (!context.Elements.Contains(typeName)) context.Elements.Add(typeName); + return typeName; + } + + /// The identity a view's example names, or else the test's first; null when there is none. + private string? documentKey(string typeName, EmlangRef view) + { + foreach (var (prop, text) in view.Props) + { + var (member, _) = context.Member(typeName, prop, view.Values.GetValueOrDefault(prop)); + if (member.EndsWith("Id", StringComparison.Ordinal) && _ids.TryGetValue(text, out var id)) return id.Variable; + } + + return anyIdentity(); + } + + /// The test's first identity, which is usually the stream the view is projected from. + private string? anyIdentity() + => _ids.Values.FirstOrDefault(x => context.Primitive(x.Type) == "Guid").Variable; + + /// + /// A view arranged directly: its example as a partial, with the document's Id set to the + /// identity it is keyed by, so the specs that read it back find it. + /// + private PartialSpec documentPartial(string typeName, EmlangRef view) + { + var built = partial(view); + if (!built.Has("Id") && documentKey(typeName, view) is { } key) built.AddVariable("Id", key, first: true); + return built; + } + } + + /// + /// The most .With(...) calls a partial object is written with. One that names more is written as + /// a Property | Value table instead, which reads far better than a chain that runs off the screen. + /// + public const int MaxWithCalls = 3; + + /// The margin a generated .With(...) chain wraps at, one member to a line. + public const int MaxLineLength = 120; + + /// + /// One partial object in a specification: its members in the order named, each with the C# + /// expression a .With(...) chain writes and the cell text a table writes. + /// + private sealed class PartialSpec(string typeName) + { + // Cell is null where the value has no faithful table form, which keeps the whole object a chain + private readonly List<(string Member, string Expression, string? Cell)> _members = []; + + public string TypeName => typeName; + + public bool Has(string member) => _members.Any(x => x.Member == member); + + public void Add(string member, string expression, string? cell) => _members.Add((member, expression, cell)); + + /// A member set to a local, which a table writes as an interpolation. + public void AddVariable(string member, string variable, bool first = false) + { + var entry = (member, variable, "{{" + variable + "}}"); + if (first) _members.Insert(0, entry); + else _members.Add(entry); + } + + /// The column the partial starts at, so a chain that would run past the margin wraps. + public string Render(int column = 0) + { + var oneLine = $"Specify<{typeName}>()" + string.Concat(_members.Select(x => $".With(x => x.{x.Member}, {x.Expression})")); + + // A chain of two or more that fits stays on one line; a longer one, or a value with no + // faithful cell form past the table threshold, puts each member on its own line + var wraps = _members.Count >= 2 && column + oneLine.Length + 2 > MaxLineLength; + if (_members.Count <= MaxWithCalls && !wraps) return oneLine; + + if (_members.Count <= MaxWithCalls || _members.Any(x => x.Cell is null)) + { + return $"Specify<{typeName}>()" + string.Concat(_members.Select(x => $"\n .With(x => x.{x.Member}, {x.Expression})")); + } + + var width = Math.Max("Property".Length, _members.Max(x => x.Member.Length)); + var valueWidth = Math.Max("Value".Length, _members.Max(x => x.Cell!.Length)); + var interpolated = _members.Any(x => x.Cell!.Contains("{{", StringComparison.Ordinal)); + + var builder = new StringBuilder($"Specify<{typeName}>({(interpolated ? "$$" : "")}\"\"\"\n"); + builder.Append($" | {"Property".PadRight(width)} | {"Value".PadRight(valueWidth)} |\n"); + foreach (var (member, _, cell) in _members) + { + builder.Append($" | {member.PadRight(width)} | {cell!.PadRight(valueWidth)} |\n"); + } + + builder.Append(" \"\"\")"); + return builder.ToString(); + } + } + + /// + /// An example value as table cell text, read back by the cell rules to the same value + /// writes; null when it has no faithful cell form. + /// + public static string? Cell(string type, string text, object? raw = null) => Cell(type, text, raw, StrongTypedIds.None); + + /// + /// + /// The strong-typed identifiers in force. A typed identity has NO cell form here (bobcat#512): the + /// value is written as code instead — which is what a null answer here means. (CellValues can + /// read an OrderId since bobcat#542, through its IParsable; this writer has not + /// changed to use that.) + /// + public static string? Cell(string type, string text, object? raw, StrongTypedIds ids) + { + if (ids.Contains(type)) return null; + + var trimmed = text.Trim(); + if (type == "string") + { + if (text.Length == 0) return "EMPTY"; + + // A cell is trimmed and split on pipes, NULL and EMPTY are keywords, and braces would + // read as an interpolation in the $$ raw string + if (text != trimmed || text.IndexOfAny(['|', '\n', '\r', '{', '}', '"']) >= 0) return null; + if (text is "NULL" or "EMPTY") return null; + return text; + } + + var literal = Literal(type, text, raw); + if (literal.StartsWith("default", StringComparison.Ordinal) || literal.Contains("/*", StringComparison.Ordinal)) return null; + + return type switch + { + "int" or "long" or "decimal" or "double" or "bool" or "Guid" or "DateTimeOffset" or "DateOnly" or "TimeSpan" + when trimmed.IndexOfAny(['|', '{', '}', '"']) < 0 => trimmed, + _ => null + }; + } + + /// An element's type name, exactly as the stub writer names its stub. + public static string TypeName(string label) + => CSharpModelWriter.Identifiers.Sanitize(EmlangImport.PascalName(label)); + + /// A C# expression of for an example value. + public static string Literal(string type, string text, object? raw = null) => Literal(type, text, raw, StrongTypedIds.None); + + /// + /// + /// The strong-typed identifiers in force: a typed identity is its inner value wrapped in the type + /// (bobcat#512), so the one rule for reading an example value is still the primitive's. + /// + public static string Literal(string type, string text, object? raw, StrongTypedIds ids) + { + if (ids.Inner(type) is { } inner) return $"new {type}({Literal(inner, text, raw, ids)})"; + + var trimmed = text.Trim(); + + switch (type) + { + case "string": + return quote(text); + + case CuratedFieldTypes.StringList: + var items = raw is List list + ? list.Select(EmlangReader.Text) + : trimmed.Length == 0 ? [] : [trimmed]; + var quoted = items.Select(quote).ToList(); + return quoted.Count == 0 ? "new List()" : $"new List {{ {string.Join(", ", quoted)} }}"; + } + + if (trimmed.Length == 0) return "default"; + + return type switch + { + "int" when int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i.ToString(CultureInfo.InvariantCulture), + "long" when long.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l.ToString(CultureInfo.InvariantCulture) + "L", + "decimal" when decimal.TryParse(trimmed, NumberStyles.Number, CultureInfo.InvariantCulture, out var m) => m.ToString(CultureInfo.InvariantCulture) + "m", + "double" when double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => d.ToString("R", CultureInfo.InvariantCulture) + "d", + "bool" when bool.TryParse(trimmed, out var b) => b ? "true" : "false", + "Guid" when Guid.TryParse(trimmed, out _) => $"Guid.Parse({quote(trimmed)})", + "Guid" => $"Guid.CreateVersion7() /* {comment(trimmed)} in the model */", + "DateTimeOffset" when DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, out _) => $"DateTimeOffset.Parse({quote(trimmed)})", + "DateOnly" when DateOnly.TryParse(trimmed, CultureInfo.InvariantCulture, out _) => $"DateOnly.Parse({quote(trimmed)})", + "TimeSpan" when TimeSpan.TryParse(trimmed, CultureInfo.InvariantCulture, out _) => $"TimeSpan.Parse({quote(trimmed)})", + _ => $"default /* {comment(trimmed)} in the model */" + }; + } + + private static string unique(HashSet taken, string name) + { + var candidate = name; + for (var i = 2; !taken.Add(candidate); i++) candidate = $"{name}_{i}"; + return candidate; + } + + private static string snake(string text) + { + var words = Regex.Matches(text, "[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+") + .Select(x => x.Value.ToLowerInvariant()) + .ToList(); + + var name = string.Join("_", words); + if (name.Length == 0) return "unnamed"; + return char.IsDigit(name[0]) ? "_" + name : name; + } + + private static string quote(string value) + => "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r").Replace("\n", "\\n") + "\""; + + private static string comment(string value) => value.Replace("*/", "* /").Replace("\n", " "); +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel/SpecFileWriter.cs b/src/Bobcat/Wolverine.Bobcat.EventModel/SpecFileWriter.cs new file mode 100644 index 000000000..4582f2e7f --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel/SpecFileWriter.cs @@ -0,0 +1,140 @@ +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Wolverine.Bobcat.EventModel; + +/// +/// Writes the specification files to disk, the arm of Bobcat.EventModel's that moved +/// here with the spec writer (GH-5082). It reports into the same the stubs and +/// definitions were written through, so one run has one list of files, callouts and report lines, and it shares that +/// writer's index of each directory. +/// +/// +/// A file that does not exist is written; with --force every file is overwritten; otherwise an example with +/// no specification gets one in the existing class bound to its feature, wherever that class is. +/// TestSupport.cs is never merged into, only written when missing; GlobalUsings.cs gains the +/// namespaces it lacks. +/// +public sealed class SpecFileWriter(ImportFileWriter files, bool force) +{ + private static readonly CSharpParseOptions parse = new(LanguageVersion.Preview); + + public ImportWriteCounts WriteSpecs(string directory, IEnumerable generated) + { + var index = files.IndexOf(directory); + int written = 0, merged = 0, kept = 0; + + foreach (var file in generated) + { + var relative = file.Path.Replace('/', Path.DirectorySeparatorChar); + var path = Path.Combine(directory, relative); + + if (force) + { + // --force keeps its meaning: overwrite, as the import always did + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, file.Content); // UTF-8 without a BOM + files.Files.Add(new ImportedFile(path, "write")); + written++; + continue; + } + + switch (writeOne(directory, file, path, relative, index)) + { + case Outcome.Written: + files.Files.Add(new ImportedFile(path, "write")); + written++; + break; + case Outcome.Merged: + case Outcome.MergedElsewhere: + // mergeInto() recorded the file it wrote, which may be another than this path + merged++; + break; + case Outcome.Kept: + files.Files.Add(new ImportedFile(path, "kept")); + kept++; + break; + } + } + + return new ImportWriteCounts(written, merged, kept); + } + + private Outcome writeOne(string directory, GeneratedFile file, string path, string relative, SourceIndex index) + { + if (file.Path == EmlangSpecWriter.TestSupportFile) + { + return File.Exists(path) ? Outcome.Kept : write(path, file.Content, index); + } + + if (file.Path == EmlangSpecWriter.GlobalUsingsFile) + { + return File.Exists(path) + ? mergeInto(path, relative, SpecMerge.MergeGlobalUsings(File.ReadAllText(path), file.Content, path), index, "global using", "global usings") + : write(path, file.Content, index); + } + + var features = CSharpSyntaxTree.ParseText(file.Content, parse).GetCompilationUnitRoot() + .DescendantNodes().OfType() + .Select(ImportMerge.FeatureOf).OfType().ToList(); + + // Into the class bound to the feature, which may have been moved to another file + var holders = features.SelectMany(index.FilesWithFeature).Distinct(StringComparer.Ordinal).ToList(); + var target = holders.Contains(Path.GetFullPath(path)) ? Path.GetFullPath(path) : holders.Count == 1 ? holders[0] : null; + + if (target is null && holders.Count > 1) + { + files.Callouts.Add(new ImportCallout(features.FirstOrDefault() ?? "", ImportCalloutCodes.MergeSkippedAmbiguous, [], + $"{holders.Count} files hold a class bound to [BobcatFeature(\"{features.FirstOrDefault()}\")] " + + $"({string.Join(", ", holders.Select(x => Path.GetRelativePath(directory, x)))}), so where {relative}'s new specifications go is ambiguous.", null) + { File = path }); + return File.Exists(path) ? Outcome.Kept : Outcome.Skipped; + } + + if (target is null) + { + if (!File.Exists(path)) return write(path, file.Content, index); + target = Path.GetFullPath(path); + } + + var result = SpecMerge.MergeSpecs(File.ReadAllText(target), file.Content, target, index); + var outcome = mergeInto(target, Path.GetRelativePath(directory, target), result, index, "specification", "specifications"); + + // Merged into another file: this path itself was never written + return target == Path.GetFullPath(path) || outcome != Outcome.Merged ? outcome : Outcome.MergedElsewhere; + } + + private enum Outcome + { + Written, + Merged, + MergedElsewhere, + Kept, + Skipped + } + + private static Outcome write(string path, string content, SourceIndex index) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); // UTF-8 without a BOM + index.Update(path, content); + return Outcome.Written; + } + + private Outcome mergeInto(string path, string relative, ImportMerge.Result result, SourceIndex index, string singular, string plural) + { + files.Callouts.AddRange(result.Callouts); + if (result.Content is null) return Outcome.Kept; + + File.WriteAllText(path, result.Content); + index.Update(path, result.Content); + files.Files.Add(new ImportedFile(path, "merge")); + files.Lines.Add(result.Description is { } description + ? $"Added {description} to {relative}." + : $"Added {result.Added.Count} {(result.Added.Count == 1 ? singular : plural)} to {relative}: {string.Join(", ", result.Added)}."); + return Outcome.Merged; + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel/SpecMerge.cs b/src/Bobcat/Wolverine.Bobcat.EventModel/SpecMerge.cs new file mode 100644 index 000000000..6ea2072b0 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel/SpecMerge.cs @@ -0,0 +1,175 @@ +using System.Text; +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Wolverine.Bobcat.EventModel; + +/// +/// The specification arm of Bobcat.EventModel's additive re-import (ImportMerge), which moved here with the spec +/// writer (GH-5082, JasperFx/bobcat#744): a re-run adds the board's new examples to the spec project and changes +/// nothing already there. Bobcat.EventModel keeps merging stubs and definitions; the small syntax helpers at the end +/// are copies of its private ones, so the two arms insert text the same way. +/// +public static class SpecMerge +{ + private static readonly CSharpParseOptions parse = new(LanguageVersion.Preview); + + /// + /// Add the specifications 's feature class has that no class bound to the + /// same [BobcatFeature] in the spec project has, at the end of that class. Existing + /// specification methods are never touched. The same rule + /// reports by: a feature and a method name. + /// + /// The file holding the feature class, as it is. + /// The specification file a fresh import would write. + /// The existing file's full path. + /// Every feature class and specification method the spec project declares. + public static ImportMerge.Result MergeSpecs(string existing, string generated, string path, SourceIndex index) + { + var tree = TryParse(existing, out var error); + if (tree is null) return ImportMerge.Result.Unchanged(unparseable(path, error)); + + var fresh = CSharpSyntaxTree.ParseText(generated, parse).GetCompilationUnitRoot(); + var root = tree.GetCompilationUnitRoot(); + var text = existing; + var newline = newlineOf(text); + var edits = new List<(int, string)>(); + var added = new List(); + var callouts = new List(); + + foreach (var feature in fresh.DescendantNodes().OfType()) + { + if (ImportMerge.FeatureOf(feature) is not { } name) continue; + + var missing = feature.Members.OfType() + .Where(x => !index.HasSpec(name, x.Identifier.ValueText)) + .ToList(); + if (missing.Count == 0) continue; + + var targets = root.DescendantNodes().OfType().Where(x => ImportMerge.FeatureOf(x) == name).ToList(); + if (targets.Count != 1) + { + callouts.Add(targets.Count == 0 + ? noInsertionPoint(path, $"{Path.GetFileName(path)} has no class bound to [BobcatFeature(\"{name}\")], so {string.Join(", ", missing.Select(x => x.Identifier.ValueText))} could not be added. Copy {(missing.Count == 1 ? "it" : "them")} from a fresh import (--specs-out to an empty directory).") with { Slice = name } + : ambiguous(path, $"{Path.GetFileName(path)} has {targets.Count} classes bound to [BobcatFeature(\"{name}\")], so where the new specifications go is ambiguous.") with { Slice = name }); + continue; + } + + var target = targets[0]; + if (target.OpenBraceToken.IsMissing || target.OpenBraceToken.Span.Length == 0) + { + callouts.Add(noInsertionPoint(path, $"The class bound to [BobcatFeature(\"{name}\")] has no body to add specifications to.") with { Slice = name }); + continue; + } + + var block = string.Join("\n\n", missing.Select(x => Reindent(x.ToFullString(), ""))); + if (target.Members.Count > 0) + { + var indent = IndentOf(text, target.Members[0].SpanStart); + edits.Add(insertion(text, target.Members.Last().FullSpan.End, Reindent(block, indent), newline, blankBefore: true)); + } + else + { + var indent = IndentOf(text, target.SpanStart) + " "; + edits.Add(insertion(text, target.OpenBraceToken.FullSpan.End, Reindent(block, indent), newline, blankBefore: false)); + } + + foreach (var method in missing) + { + var spec = $"{name} / {method.Identifier.ValueText}"; + added.Add(spec); + callouts.Add(callout(ImportCalloutCodes.MergedSpec, path, + $"Added the specification {method.Identifier.ValueText} to {target.Identifier.ValueText} in {Path.GetFileName(path)}: the board has the example and the spec project had no specification for it.") with { Slice = name }); + } + } + + return new ImportMerge.Result(edits.Count == 0 ? null : apply(text, edits), added, callouts); + } + + /// The global using directives has that lacks, added after its last. + public static ImportMerge.Result MergeGlobalUsings(string existing, string generated, string path) + { + var tree = TryParse(existing, out var error); + if (tree is null) return ImportMerge.Result.Unchanged(unparseable(path, error)); + + var root = tree.GetCompilationUnitRoot(); + var have = root.Usings.Where(x => x.GlobalKeyword.Span.Length > 0).Select(x => x.Name?.ToString()).ToHashSet(StringComparer.Ordinal); + var fresh = CSharpSyntaxTree.ParseText(generated, parse).GetCompilationUnitRoot(); + var missing = fresh.Usings.Select(x => x.Name?.ToString()).OfType().Where(x => !have.Contains(x)).ToList(); + if (missing.Count == 0) return ImportMerge.Result.Unchanged(); + + var newline = newlineOf(existing); + var block = string.Join("\n", missing.Select(x => $"global using {x};")); + var position = root.Usings.Count > 0 ? root.Usings.Last().FullSpan.End : root.EndOfFileToken.FullSpan.Start; + var content = apply(existing, [insertion(existing, position, block, newline, blankBefore: false)]); + + return new ImportMerge.Result(content, missing, missing.Select(x => callout(ImportCalloutCodes.MergedUsing, path, + $"Added global using {x} to {Path.GetFileName(path)}: the stubs now declare types there.")).ToList()); + } + + // ---------------------------------------------------------------- syntax helpers, as ImportMerge has them + + private static SyntaxTree? TryParse(string text, out string? error) + { + var tree = CSharpSyntaxTree.ParseText(text, parse); + var first = tree.GetDiagnostics().FirstOrDefault(x => x.Severity == DiagnosticSeverity.Error); + error = first is null ? null : $"{first.Id} at line {first.Location.GetLineSpan().StartLinePosition.Line + 1}: {first.GetMessage()}"; + return first is null ? tree : null; + } + + private static string Reindent(string block, string indent) + { + var lines = block.Replace("\r\n", "\n").Split('\n').Select(x => x.TrimEnd()).ToList(); + while (lines.Count > 0 && lines[0].Length == 0) lines.RemoveAt(0); + while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); + + var common = lines.Where(x => x.Length > 0).Select(x => x.TakeWhile(char.IsWhiteSpace).Count()).DefaultIfEmpty(0).Min(); + return string.Join("\n", lines.Select(x => x.Length == 0 ? "" : indent + x[common..])); + } + + private static string IndentOf(string text, int position) + { + var lineStart = text.LastIndexOf('\n', Math.Max(0, position - 1)) + 1; + return new string(text.Skip(lineStart).Take(position - lineStart).TakeWhile(c => c is ' ' or '\t').ToArray()); + } + + private static string newlineOf(string text) => text.Contains("\r\n") ? "\r\n" : "\n"; + + /// + /// The text to insert at , which is normally the start of a line: the + /// block on lines of its own, after a blank line when . + /// + private static (int, string) insertion(string text, int position, string block, string newline, bool blankBefore) + { + var builder = new StringBuilder(); + var atLineStart = position == 0 || text[position - 1] == '\n'; + if (!atLineStart) builder.Append('\n'); + if (blankBefore) builder.Append('\n'); + builder.Append(block); + builder.Append('\n'); + + // A block written at the very end of a file with no final newline keeps the file's last line its own + return (position, builder.ToString().Replace("\n", newline)); + } + + /// Apply insertions, last first, so every offset still means what Roslyn said. + private static string apply(string text, IReadOnlyList<(int Position, string Text)> edits) + { + var builder = new StringBuilder(text); + foreach (var (position, inserted) in edits.OrderByDescending(x => x.Position)) builder.Insert(position, inserted); + return builder.ToString(); + } + + private static ImportCallout callout(string code, string path, string message) => new("", code, [], message, null) { File = path }; + + private static ImportCallout unparseable(string path, string? error) + => callout(ImportCalloutCodes.MergeSkippedUnparseable, path, + $"{Path.GetFileName(path)} does not parse ({error}), so nothing was merged into it. Fix it and import again."); + + private static ImportCallout noInsertionPoint(string path, string message) => callout(ImportCalloutCodes.MergeSkippedNoInsertionPoint, path, message); + + private static ImportCallout ambiguous(string path, string message) => callout(ImportCalloutCodes.MergeSkippedAmbiguous, path, message); +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel/SpecProjectWriter.cs b/src/Bobcat/Wolverine.Bobcat.EventModel/SpecProjectWriter.cs new file mode 100644 index 000000000..c8a6731d9 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel/SpecProjectWriter.cs @@ -0,0 +1,506 @@ +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; + +namespace Wolverine.Bobcat.EventModel; + +/// What is asked to set up. +/// The spec project's directory, where the specifications are written. +/// The spec project's root namespace, which names its .csproj. +/// The model name, which [assembly: EventModelName] declares. +/// The application's .csproj, or null when none was found. +/// The Bobcat packages' version; by default. +/// WolverineFx.Bobcat's version; by default. +public sealed record SpecProjectRequest( + string SpecsDirectory, + string SpecsNamespace, + string ModelName, + string? AppProject, + string? BobcatVersion = null, + string? WolverineFxBobcatVersion = null); + +/// A spec project file to write, at a full path. The writer never returns a file that already exists. +public sealed record SpecProjectFile(string Path, string Content); + +/// What decided: the files to write, and what to tell the person. +public sealed record SpecProjectPlan( + IReadOnlyList Files, + IReadOnlyList Kept, + IReadOnlyList Callouts, + IReadOnlyList Report); + +/// +/// The spec project write-specs writes the specifications into: the .csproj, a +/// global.json and the assembly attributes, when the spec directory holds no project yet, and the +/// checks on the application the specifications run against. +/// +/// +/// +/// Written once, never overwritten, not even with --force. These are build files the +/// person owns from the first edit. When the spec directory already holds a .csproj nothing here is +/// written; the existing project is checked instead, and anything missing is a callout. +/// +/// +/// The application is never edited. A missing public partial class Program, +/// RunJasperFxCommands or WolverineFx.RuntimeCompilation reference is a callout carrying the +/// exact line or command that settles it. +/// +/// +/// global.json goes where dotnet test will find it. The SDK reads the global.json nearest +/// the directory the command runs in, so an existing one anywhere above the spec project governs it and +/// is checked, never shadowed. With none, it is written at the solution root (the nearest directory +/// above the spec project holding a .sln or .slnx, else the git root), so dotnet test from the +/// root and from the spec project both run on Microsoft.Testing.Platform; with neither, beside the +/// spec project. +/// +/// +public static class SpecProjectWriter +{ + /// + /// The WolverineFx.Bobcat used when neither --wolverine-version nor the + /// WolverineFxBobcatVersion property says otherwise: this package's own version, because + /// WolverineFx.Bobcat and WolverineFx.Bobcat.EventModel ship together from one Wolverine build (GH-5082). + /// While this lived in Bobcat it was a constant someone had to move with every pairing. + /// + public static string DefaultWolverineFxBobcatVersion => ToolVersion; + + /// The file holding [assembly: EventModelName], the parallelization and the content root. + public const string AssemblyAttributesFile = "AssemblyAttributes.cs"; + + public const string GlobalJsonFile = "global.json"; + + // Third-party pins, the ones Bobcat's own projects and Wolverine's spec projects build with + internal const string XunitV3Version = "4.0.2"; + internal const string TestSdkVersion = "17.14.1"; + internal const string XunitRunnerVisualStudioVersion = "4.0.1"; + internal const string AlbaVersion = "8.5.2"; + + /// + /// The Bobcat packages' version when --bobcat-version does not say: the Bobcat this package was built + /// against, stamped by its build from the repository's central version (BobcatVersion assembly metadata). + /// + public static string BobcatVersion + => typeof(SpecProjectWriter).Assembly.GetCustomAttributes() + .FirstOrDefault(x => x.Key == "BobcatVersion")?.Value is { Length: > 0 } stamped + ? stamped + : throw new InvalidOperationException("This WolverineFx.Bobcat.EventModel carries no BobcatVersion metadata; pass --bobcat-version."); + + /// This package's own version, which is WolverineFx.Bobcat's. + public static string ToolVersion + { + get + { + var assembly = typeof(SpecProjectWriter).Assembly; + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (informational is { Length: > 0 }) return informational.Split('+')[0]; + return assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + } + } + + /// The packages the generated specifications need, with the property or version each takes. + internal static IReadOnlyList<(string Id, string Version, bool Private)> Packages(string bobcat, string wolverine) => + [ + ("xunit.v3", XunitV3Version, false), + ("Bobcat", bobcat, false), + ("Bobcat.Xunit", bobcat, false), + ("Bobcat.Generators", bobcat, true), + ("WolverineFx.Bobcat", wolverine, false), + ("Alba", AlbaVersion, false), + ("Microsoft.NET.Test.Sdk", TestSdkVersion, false), + ("xunit.runner.visualstudio", XunitRunnerVisualStudioVersion, true) + ]; + + /// + /// The application project for (the --out directory): the one .csproj + /// in it. Null when there is none, or several. + /// + public static string? FindAppProject(string outDirectory) + { + if (!Directory.Exists(outDirectory)) return null; + var projects = Directory.GetFiles(outDirectory, "*.csproj"); + return projects.Length == 1 ? Path.GetFullPath(projects[0]) : null; + } + + public static SpecProjectPlan Plan(SpecProjectRequest request) + { + var specs = Path.GetFullPath(request.SpecsDirectory); + var bobcat = request.BobcatVersion is { Length: > 0 } b ? b : BobcatVersion; + var wolverine = request.WolverineFxBobcatVersion is { Length: > 0 } w ? w : DefaultWolverineFxBobcatVersion; + var files = new List(); + var kept = new List(); + var callouts = new List(); + var report = new List(); + + var app = request.AppProject is null ? null : Path.GetFullPath(request.AppProject); + if (app is not null && !File.Exists(app)) app = null; + + var central = findUp(specs, "Directory.Packages.props") is { } packagesProps && isCentral(File.ReadAllText(packagesProps)) + ? packagesProps + : null; + + var existing = Directory.Exists(specs) ? Directory.GetFiles(specs, "*.csproj") : []; + string csprojPath; + if (existing.Length > 0) + { + csprojPath = Path.GetFullPath(existing[0]); + kept.Add(new SpecProjectFile(csprojPath, "")); + callouts.Add(projectCallout(ImportCalloutCodes.SpecProjectFileKept, csprojPath, + $"Kept the spec project {csprojPath}, which already exists; nothing in it was changed.", null)); + checkExistingProject(csprojPath, specs, request.ModelName, callouts); + } + else + { + csprojPath = Path.Combine(specs, request.SpecsNamespace + ".csproj"); + files.Add(new SpecProjectFile(csprojPath, WriteCsproj(request.SpecsNamespace, app, specs, bobcat, wolverine, central is not null))); + report.Add($"spec project: {csprojPath}, on xunit.v3 {XunitV3Version} and Microsoft.Testing.Platform, Bobcat {bobcat}, " + + $"WolverineFx.Bobcat {wolverine} (--wolverine-version, or the WolverineFxBobcatVersion property, changes it)."); + + var attributes = Path.Combine(specs, AssemblyAttributesFile); + if (File.Exists(attributes)) + { + kept.Add(new SpecProjectFile(attributes, "")); + callouts.Add(projectCallout(ImportCalloutCodes.SpecProjectFileKept, attributes, + $"Kept {attributes}, which already exists. It needs [assembly: EventModelName(\"{request.ModelName}\")].", + $"[assembly: EventModelName(\"{request.ModelName}\")]")); + } + else + { + files.Add(new SpecProjectFile(attributes, WriteAssemblyAttributes(request.ModelName, app, specs))); + } + + if (central is not null) + { + var declared = declaredPackageVersions(File.ReadAllText(central)); + var missing = Packages(bobcat, wolverine).Where(x => !declared.Contains(x.Id)).ToList(); + if (missing.Count > 0) + { + var lines = string.Join("\n", missing.Select(x => $"")); + callouts.Add(projectCallout(ImportCalloutCodes.CentralPackageVersions, central, + $"{central} manages package versions centrally, so the spec project's PackageReferences carry no versions. Add: " + + string.Join(", ", missing.Select(x => $"{x.Id} {x.Version}")) + ".", lines)); + } + } + } + + planGlobalJson(specs, files, kept, callouts, report); + checkSolution(specs, csprojPath, callouts); + callouts.AddRange(CheckApp(app, specs)); + + return new SpecProjectPlan(files, kept, callouts, report); + } + + /// + /// What the application needs for the generated specifications to run, each missing piece a callout + /// with the exact fix. is left out of the search when it sits inside the application. + /// + public static IReadOnlyList CheckApp(string? appProject, string? specsDirectory = null) + { + if (appProject is null || !File.Exists(appProject)) + { + return + [ + projectCallout(ImportCalloutCodes.AppProjectNotFound, appProject, + "No single application .csproj was found in --out, so the spec project has no ProjectReference to the application " + + "and the checks on Program were skipped. Pass --app-project, or add the reference by hand.", + "\\.csproj\" />") with { Override = "--app-project " } + ]; + } + + var callouts = new List(); + var appDirectory = Path.GetDirectoryName(Path.GetFullPath(appProject))!; + var sources = sourceFiles(appDirectory, specsDirectory).ToList(); + var texts = sources.Select(x => (Path: x, Text: File.ReadAllText(x))).ToList(); + var program = texts.FirstOrDefault(x => Path.GetFileName(x.Path) == "Program.cs").Path + ?? Path.Combine(appDirectory, "Program.cs"); + + if (!texts.Any(x => publicProgram.IsMatch(x.Text))) + { + callouts.Add(projectCallout(ImportCalloutCodes.AppProgramNotPublic, program, + "The application declares no public Program, and the specifications start it through Alba (AlbaHost.For), " + + "which cannot see the one top-level statements generate. Add this line to the end of Program.cs.", + "public partial class Program;")); + } + + if (!texts.Any(x => x.Text.Contains("RunJasperFxCommands", StringComparison.Ordinal))) + { + callouts.Add(projectCallout(ImportCalloutCodes.AppMissingRunJasperFxCommands, program, + "The application does not run through RunJasperFxCommands, so `wolverine scaffold`, `codegen` and the event model " + + "commands cannot run against it. Replace the closing app.Run(); in Program.cs with this line.", + "return await app.RunJasperFxCommands(args);")); + } + + var csproj = File.ReadAllText(appProject); + if (!csproj.Contains("WolverineFx.RuntimeCompilation", StringComparison.OrdinalIgnoreCase) + && !csproj.Contains("Wolverine.RuntimeCompilation", StringComparison.OrdinalIgnoreCase)) + { + var version = wolverineVersionOf(appProject); + callouts.Add(projectCallout(ImportCalloutCodes.AppMissingRuntimeCompilation, appProject, + "The application references no WolverineFx.RuntimeCompilation, so a host in TypeLoadMode.Dynamic (the default) fails " + + "to start and every specification fails with it. Add the package, at the application's WolverineFx version.", + $"dotnet add \"{appProject}\" package WolverineFx.RuntimeCompilation" + (version is null ? "" : $" --version {version}"))); + } + + return callouts; + } + + /// The spec project's .csproj. + public static string WriteCsproj(string specsNamespace, string? appProject, string specsDirectory, string bobcatVersion, + string wolverineFxBobcatVersion, bool centralPackageManagement = false) + { + var framework = appProject is null ? "net10.0" : targetFrameworkOf(File.ReadAllText(appProject)); + var builder = new StringBuilder(); + builder.Append("\n\n"); + builder.Append(" \n"); + builder.Append(" \n"); + builder.Append(" Exe\n"); + builder.Append($" {framework}\n"); + builder.Append(" enable\n"); + builder.Append(" enable\n"); + builder.Append(" false\n"); + builder.Append(" true\n"); + builder.Append($" {specsNamespace}\n"); + builder.Append(" true\n"); + builder.Append(" true\n"); + builder.Append(" \n"); + builder.Append(" $(InterceptorsNamespaces);Bobcat.Generated\n"); + builder.Append(" \n\n"); + + if (!centralPackageManagement) + { + builder.Append(" \n"); + builder.Append(" \n"); + builder.Append($" {bobcatVersion}\n"); + builder.Append($" {wolverineFxBobcatVersion}\n"); + builder.Append(" \n\n"); + } + + builder.Append(" \n"); + foreach (var (id, version, isPrivate) in Packages("$(BobcatVersion)", "$(WolverineFxBobcatVersion)")) + { + var versionAttribute = centralPackageManagement ? "" : $" Version=\"{version}\""; + if (isPrivate) + { + builder.Append($" \n"); + builder.Append(id == "Bobcat.Generators" + ? " analyzers; build; buildtransitive\n" + : " runtime; build; native; contentfiles; analyzers; buildtransitive\n"); + builder.Append(" all\n"); + builder.Append(" \n"); + } + else + { + builder.Append($" \n"); + } + } + + builder.Append(" \n\n"); + builder.Append(" \n"); + if (appProject is null) + { + builder.Append(" \n"); + builder.Append(" \n"); + } + else + { + builder.Append($" \n"); + } + + builder.Append(" \n\n"); + builder.Append("\n"); + return builder.ToString(); + } + + /// The global.json that puts dotnet test on Microsoft.Testing.Platform. + public const string GlobalJson = "{\n \"test\": {\n \"runner\": \"Microsoft.Testing.Platform\"\n }\n}\n"; + + /// AssemblyAttributes.cs: the model name, serial execution and the application's content root. + public static string WriteAssemblyAttributes(string modelName, string? appProject, string specsDirectory) + { + var builder = new StringBuilder(); + builder.Append("// Written by `write-specs` (WolverineFx.Bobcat.EventModel). Nothing regenerates this file.\n\n"); + builder.Append("using Bobcat;\n"); + if (appProject is not null) builder.Append("using Microsoft.AspNetCore.Mvc.Testing;\n"); + builder.Append('\n'); + builder.Append("// The model these specifications contribute slices to. Event Model sources merge by model name, and the\n"); + builder.Append("// application's is its Wolverine ServiceName, so this must say the same or the slices float off on their own.\n"); + builder.Append($"[assembly: EventModelName(\"{escape(modelName)}\")]\n\n"); + builder.Append("// Every specification resets the event store before it runs, so they run one at a time.\n"); + builder.Append("[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)]\n"); + + if (appProject is not null) + { + var assemblyName = assemblyNameOf(appProject); + var fromBin = "../../../" + relative(specsDirectory, Path.GetDirectoryName(appProject)!); + builder.Append('\n'); + builder.Append("// Where the application's content root is, for Alba's WebApplicationFactory: relative to this project's\n"); + builder.Append("// bin/// folder, and used only when the marker file is really there, so a\n"); + builder.Append("// different output layout falls back to the factory's own search.\n"); + builder.Append($"[assembly: WebApplicationFactoryContentRoot(\"{escape(assemblyName)}\", \"{fromBin.TrimEnd('/')}\", \"{Path.GetFileName(appProject)}\", \"1\")]\n"); + } + + return builder.ToString(); + } + + private static void planGlobalJson(string specs, List files, List kept, List callouts, + List report) + { + var existing = findUp(specs, GlobalJsonFile); + if (existing is not null) + { + kept.Add(new SpecProjectFile(existing, "")); + if (!File.ReadAllText(existing).Contains("Microsoft.Testing.Platform", StringComparison.Ordinal)) + { + callouts.Add(projectCallout(ImportCalloutCodes.GlobalJsonMissingTestRunner, existing, + $"{existing} governs the spec project and does not put `dotnet test` on Microsoft.Testing.Platform, which " + + "xunit.v3 4.x needs on the .NET 10 SDK. Add the \"test\" section to it.", + "\"test\": { \"runner\": \"Microsoft.Testing.Platform\" }")); + } + + return; + } + + var root = solutionRoot(specs) ?? specs; + var path = Path.Combine(root, GlobalJsonFile); + files.Add(new SpecProjectFile(path, GlobalJson)); + report.Add($"global.json: {path}, so `dotnet test` runs on Microsoft.Testing.Platform from there and below."); + } + + private static void checkExistingProject(string csproj, string specs, string modelName, List callouts) + { + var text = File.ReadAllText(csproj); + foreach (var id in new[] { "xunit.v3", "Bobcat.Xunit", "Bobcat.Generators", "WolverineFx.Bobcat", "Alba" }) + { + if (!Regex.IsMatch(text, $"Include=\"{Regex.Escape(id)}\"", RegexOptions.IgnoreCase)) + { + // A ProjectReference to the source (Wolverine.Bobcat, Bobcat.Xunit) counts as the package + var project = id.Replace("WolverineFx.", "Wolverine."); + if (text.Contains(project + ".csproj", StringComparison.OrdinalIgnoreCase)) continue; + callouts.Add(projectCallout(ImportCalloutCodes.SpecProjectMissingReference, csproj, + $"The spec project references no {id}, which the generated specifications need.", + id == "Bobcat.Generators" + ? "" + : $"")); + } + } + + var declaresName = sourceFiles(specs, null).Any(x => File.ReadAllText(x).Contains("EventModelName", StringComparison.Ordinal)); + if (!declaresName) + { + callouts.Add(projectCallout(ImportCalloutCodes.SpecProjectMissingEventModelName, csproj, + "The spec project declares no [assembly: EventModelName], so its slices are named for the test assembly and never " + + "join the application's model.", $"[assembly: EventModelName(\"{escape(modelName)}\")]")); + } + } + + private static void checkSolution(string specs, string csproj, List callouts) + { + var root = solutionRoot(specs, gitFallback: false); + if (root is null) return; + var solutions = Directory.GetFiles(root, "*.sln").Concat(Directory.GetFiles(root, "*.slnx")).ToList(); + var name = Path.GetFileName(csproj); + if (solutions.Any(x => File.ReadAllText(x).Contains(name, StringComparison.OrdinalIgnoreCase))) return; + + var solution = solutions[0]; + callouts.Add(projectCallout(ImportCalloutCodes.SpecProjectNotInSolution, solution, + $"The spec project is not in {Path.GetFileName(solution)}, so building the solution does not build the specifications.", + $"dotnet sln \"{solution}\" add \"{csproj}\"")); + } + + private static ImportCallout projectCallout(string code, string? file, string message, string? fix) + => new("", code, [], message, null) { File = file, Fix = fix }; + + private static readonly Regex publicProgram = new(@"public\s+(?:sealed\s+)?(?:partial\s+)?class\s+Program\b", RegexOptions.Compiled); + + private static IEnumerable sourceFiles(string directory, string? excluded) + { + if (!Directory.Exists(directory)) yield break; + var skip = excluded is null ? null : Path.GetFullPath(excluded).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + foreach (var file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories)) + { + var full = Path.GetFullPath(file); + var parts = Path.GetRelativePath(directory, full).Split(Path.DirectorySeparatorChar); + if (parts.Any(x => x is "bin" or "obj")) continue; + if (skip is not null && full.StartsWith(skip, StringComparison.Ordinal)) continue; + yield return full; + } + } + + private static string? findUp(string start, string fileName) + { + for (var directory = new DirectoryInfo(start); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, fileName); + if (File.Exists(candidate)) return candidate; + } + + return null; + } + + /// The nearest directory at or above with a .sln or .slnx, else the git root. + private static string? solutionRoot(string start, bool gitFallback = true) + { + string? git = null; + for (var directory = new DirectoryInfo(start); directory is not null; directory = directory.Parent) + { + if (!directory.Exists) continue; + if (directory.EnumerateFiles("*.sln").Any() || directory.EnumerateFiles("*.slnx").Any()) return directory.FullName; + if (git is null && (Directory.Exists(Path.Combine(directory.FullName, ".git")) || File.Exists(Path.Combine(directory.FullName, ".git")))) + { + git = directory.FullName; + break; + } + } + + return gitFallback ? git : null; + } + + private static bool isCentral(string props) + => Regex.IsMatch(props, @"\s*true\s*", RegexOptions.IgnoreCase); + + private static HashSet declaredPackageVersions(string props) + => Regex.Matches(props, " x.Groups[1].Value) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// The application's WolverineFx version: off a WolverineFx package in its csproj, else its Directory.Packages.props. + private static string? wolverineVersionOf(string appProject) + { + var pattern = new Regex("Include=\"WolverineFx(?:\\.[\\w.]+)?\"\\s+Version=\"([^\"]+)\"", RegexOptions.IgnoreCase); + var match = pattern.Match(File.ReadAllText(appProject)); + if (match.Success) return match.Groups[1].Value; + var props = findUp(Path.GetDirectoryName(appProject)!, "Directory.Packages.props"); + if (props is null) return null; + match = pattern.Match(File.ReadAllText(props)); + return match.Success ? match.Groups[1].Value : null; + } + + private static string targetFrameworkOf(string csproj) + { + var match = Regex.Match(csproj, @"\s*([^<]+?)\s*"); + if (!match.Success) return "net10.0"; + return match.Groups[1].Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(x => Regex.IsMatch(x, @"^net\d+\.\d+$")) + .OrderByDescending(x => Version.Parse(x[3..])) + .FirstOrDefault() ?? "net10.0"; + } + + private static string assemblyNameOf(string appProject) + { + var match = Regex.Match(File.ReadAllText(appProject), @"\s*([^<]+?)\s*"); + return match.Success ? match.Groups[1].Value : Path.GetFileNameWithoutExtension(appProject); + } + + private static string relative(string from, string to) => Path.GetRelativePath(from, to).Replace('\\', '/'); + + private static string escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel/Wolverine.Bobcat.EventModel.csproj b/src/Bobcat/Wolverine.Bobcat.EventModel/Wolverine.Bobcat.EventModel.csproj new file mode 100644 index 000000000..e0e9821f2 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel/Wolverine.Bobcat.EventModel.csproj @@ -0,0 +1,27 @@ + + + Writes WolverineFx.Bobcat specifications, and the spec project around them, from an eventmodelers.ai board export: one specification per example on the board, bound to its Event Model slice. The write-specs command, next to Wolverine's scaffold. + WolverineFx.Bobcat.EventModel + + $(Version)-alpha.1 + net9.0;net10.0 + 13 + + false + $(NoWarn);CS0419 + + + + + + + + + + + + + + + + diff --git a/src/Bobcat/Wolverine.Bobcat.EventModel/WriteSpecsCommand.cs b/src/Bobcat/Wolverine.Bobcat.EventModel/WriteSpecsCommand.cs new file mode 100644 index 000000000..3df75611f --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.EventModel/WriteSpecsCommand.cs @@ -0,0 +1,333 @@ +using Bobcat.EventModel; +using Bobcat.EventModel.Emlang; +using JasperFx.CommandLine; + +namespace Wolverine.Bobcat.EventModel; + +public class WriteSpecsInput +{ + [Description("An eventmodelers.ai export (a board backup or config.json), or an emlang model (YAML)")] + public string FilePath { get; set; } = string.Empty; + + [Description("Model name; defaults to the file name. It is the merge key, so it must match what the code-derived sources call the model")] + [FlagAlias("model", 'm')] + public string? ModelFlag { get; set; } + + [Description("Namespace of the stubs and definition, as bobcat import-event-model wrote them; defaults to the model name")] + public string? NamespaceFlag { get; set; } + + [Description("The application project's directory, where bobcat import-event-model wrote the stubs (Features/{Chapter}/{Slice}.cs) and the definitions. The members the specifications need are merged into those stubs. Defaults beside the input")] + [FlagAlias("out", 'o')] + public string? OutFlag { get; set; } + + [Description("Directory the specification files are written to; defaults to a sibling of --out named for the spec namespace, e.g. ../CritterCrush.Specs")] + [FlagAlias("specs-out", true)] + public string? SpecsOutFlag { get; set; } + + [Description("Root namespace of the specifications, which should be the spec project's name; defaults to '.Specs'. Each chapter folder adds a segment")] + [FlagAlias("specs-namespace", true)] + public string? SpecsNamespaceFlag { get; set; } + + [Description("WolverineFx.Bobcat version for a spec project this writes (one is written when the spec directory has no .csproj); defaults to this package's own version. The csproj keeps it in the WolverineFxBobcatVersion property")] + [FlagAlias("wolverine-version", true)] + public string? WolverineVersionFlag { get; set; } + + [Description("Bobcat package version for a spec project this writes; defaults to the Bobcat this package was built against")] + [FlagAlias("bobcat-version", true)] + public string? BobcatVersionFlag { get; set; } + + [Description("The application's .csproj, which the spec project references and the checks on Program read; defaults to the one .csproj in --out")] + [FlagAlias("app-project", true)] + public string? AppProjectFlag { get; set; } + + [Description("The application's event store -- marten, polecat or fisher -- which decides how the generated test host runs its async daemon")] + [FlagAlias("store", true)] + public string? StoreFlag { get; set; } + + [Description("The stubs carry one EventModelDefinition for the whole model instead of one per chapter (as bobcat import-event-model --single-definition wrote them)")] + [FlagAlias("single-definition", true)] + public bool SingleDefinitionFlag { get; set; } + + [Description("The stubs give each aggregate a strong-typed identifier (as bobcat import-event-model --strong-typed-ids wrote them), so the specifications declare identities as those types")] + [FlagAlias("strong-typed-ids", true)] + public bool StrongTypedIdsFlag { get; set; } + + [Description("The aggregate a slice decides against, as Slice=Type, which wins over the one inferred from the examples. The same overrides bobcat import-event-model was given")] + [FlagAlias("aggregate", true)] + public List AggregateFlag { get; set; } = []; + + [Description("The aggregate whose stream a slice STARTS, as Slice=Type, which wins over the inference. The same overrides bobcat import-event-model was given")] + [FlagAlias("starts-stream", true)] + public List StartsStreamFlag { get; set; } = []; + + [Description("Write specifications for only these board chapters, space-separated: --chapter A B")] + [FlagAlias("chapter", true)] + public List ChapterFlag { get; set; } = []; + + [Description("Write the run to stdout as JSON -- every callout with its stable code, the report, and every file -- for tooling such as Stoat, instead of the text report")] + [FlagAlias("json", true)] + public bool JsonFlag { get; set; } + + [Description("Overwrite specification and stub files that already exist. Without it an existing file is merged into: the board's new specifications and members are added and nothing already there is changed. The spec project's own files are never overwritten")] + public bool ForceFlag { get; set; } +} + +/// +/// write-specs <board>: one WolverineFx.Bobcat specification per example on an eventmodelers.ai board, +/// bound to its Event Model slice, and the spec project around them when the spec directory has none. This is what +/// bobcat import-event-model --specs did until the spec writer moved to the Wolverine repository with the +/// Critter Stack vocabulary the specifications are written against (GH-5082, JasperFx/bobcat#744). +/// +/// +/// +/// The workflow: bobcat import-event-model board.json --out App writes the stubs and the +/// EventModelDefinitions; then this command, run with the same board and the same import flags, writes the +/// specifications. A generated specification can name a member the board gives only in an example, so the stubs +/// gain those members here, through the same additive merge the import uses: nothing in them is changed or removed, +/// and running it twice changes nothing the second time. Run alone, it writes the stubs and definitions too, exactly +/// as the old --specs import did. +/// +/// +/// It runs in the application, next to scaffold: an application referencing WolverineFx.Bobcat.EventModel +/// gets it from dotnet run -- write-specs. It never builds or starts the host; everything it reads is the +/// board and the files on disk. +/// +/// +[Description("Write a WolverineFx.Bobcat specification for every example on an eventmodelers.ai board, and the spec project around them", + Name = "write-specs")] +public class WriteSpecsCommand : JasperFxAsyncCommand +{ + public WriteSpecsCommand() + { + Usage("Write the specifications for a board export").Arguments(x => x.FilePath); + } + + public override async Task Execute(WriteSpecsInput input) + { + if (!File.Exists(input.FilePath)) + { + await Console.Error.WriteLineAsync($"No file at {input.FilePath}"); + return false; + } + + var text = await File.ReadAllTextAsync(input.FilePath); + + // With --json nothing but the JSON reaches stdout, so a tool can parse it whole + var run = new SpecRun(input.JsonFlag ? TextWriter.Null : Console.Out); + + var model = EventModelFileSniffer.Sniff(text) switch + { + EventModelFileKind.Emlang or EventModelFileKind.EventModelersBoard or EventModelFileKind.EventModelersConfig + => write(input, text, run), + EventModelFileKind.Curated => refuse( + $"{input.FilePath} is a curated event-model file, which is no longer an authoring format. Specifications are " + + "written from the eventmodelers.ai board export the model was imported from."), + _ => refuse( + $"{input.FilePath} is not an emlang file. Expected a top-level `slices:` map, each slice a list of steps or a map " + + "of `steps:` and `tests:`. (An eventmodelers.ai export is a board backup or a config.json.)") + }; + + if (model is null) return false; + + if (input.JsonFlag) + { + await Console.Out.WriteLineAsync(ImportJson.Write(model, run.Callouts, run.Report, run.Files)); + } + + return true; + } + + /// What one run reports: the text, or with --json the callouts, report and files the JSON carries. + private sealed class SpecRun(TextWriter output) + { + public TextWriter Out { get; } = output; + public IReadOnlyList Callouts { get; set; } = []; + public List Report { get; } = []; + public List Files { get; set; } = []; + + public void Warn(IEnumerable callouts) + { + foreach (var callout in callouts) + { + Out.WriteLine($"⚠ [{callout.Code}] {callout.Message}"); + if (callout.Fix is not null) Out.WriteLine($" {callout.Fix.Replace("\n", "\n ")}"); + } + } + } + + private static string? refuse(string message) + { + Console.Error.WriteLine(message); + return null; + } + + private static string? write(WriteSpecsInput input, string text, SpecRun run) + { + EmlangBoard board; + try + { + board = EventModelersJsonReader.LooksLikeJson(text) ? EventModelersJsonReader.Read(text) : EmlangReader.Read(text); + } + catch (EmlangFormatException e) + { + Console.Error.WriteLine($"Not readable: {e.Message}"); + return null; + } + + var model = input.ModelFlag ?? EmlangImport.PascalName(Path.GetFileName(input.FilePath).Split('.')[0]); + + IReadOnlyList overrides; + try + { + overrides = input.AggregateFlag.Select(AggregateOverride.Parse) + .Concat(input.StartsStreamFlag.Select(AggregateOverride.ParseStartsStream)) + .ToList(); + } + catch (FormatException e) + { + Console.Error.WriteLine(e.Message); + return null; + } + + EmlangImportResult result; + try + { + result = EmlangImport.ToCurated(board, model, input.NamespaceFlag, overrides, input.ChapterFlag); + } + catch (ArgumentException e) when (e.ParamName == "chapters") + { + Console.Error.WriteLine(e.Message.Replace($" (Parameter '{e.ParamName}')", "")); + return null; + } + + // A slice the board moved to a chapter left out is still on the board, so a run over some chapters + // compares the definitions against the whole board's slice names (slice-removed-on-board) + var boardSlices = input.ChapterFlag.Count == 0 + ? null + : EmlangImport.ToCurated(board, model, input.NamespaceFlag).Model.Slices.Select(ImportMerge.SliceName).ToList(); + + // The spec writer reads the chapters imported, never the ones left out + board = result.Board ?? board; + run.Callouts = result.Callouts; + run.Report.AddRange(result.Report); + foreach (var line in result.Report) run.Out.WriteLine(line); + + var ns = input.NamespaceFlag ?? CSharpModelWriter.Identifiers.Sanitize(model); + var specsNamespace = input.SpecsNamespaceFlag ?? ns + ".Specs"; + + // One decision, handed to both writers (bobcat#512): the specifications declare identities as the typed + // values the stubs take, so they cannot be written against two different answers + var identities = input.StrongTypedIdsFlag + ? StrongTypedIds.For(result.Model, CSharpModelWriter.StubNames(result.Model)) + : StrongTypedIds.None; + + var specs = EmlangSpecWriter.Write(board, result.Model, ns, specsNamespace, input.StoreFlag, identities); + var generated = CSharpModelWriter.Write(result.Model, ns, specs.Additions, perChapter: !input.SingleDefinitionFlag, identities); + + if (identities.Callouts.Count > 0) + { + run.Callouts = run.Callouts.Concat(identities.Callouts).ToList(); + run.Warn(identities.Callouts); + } + + var outDirectory = Path.GetFullPath(input.OutFlag ?? Path.GetDirectoryName(Path.GetFullPath(input.FilePath))!); + Directory.CreateDirectory(outDirectory); + + // Nothing is overwritten without --force: an existing file is merged into instead, so the stubs gain only + // what the specifications need and a re-run is additive + var files = new ImportFileWriter(input.ForceFlag); + run.Files = files.Files; + + var stubs = files.WriteStubs(outDirectory, generated.StubFiles); + run.Out.WriteLine( + $"Stubs under {Path.Combine(outDirectory, ModelLayout.FeaturesFolder)}: wrote {stubs.Written}" + + (stubs.Merged > 0 ? $", merged the members the specifications need into {stubs.Merged}" : "") + + (stubs.Kept > 0 ? $", kept {stubs.Kept} that already have everything" : "") + "."); + + var definitionFiles = generated.DefinitionFiles.Count > 0 + ? generated.DefinitionFiles + : [new GeneratedFile($"{model}.cs", generated.Definition)]; + var definitions = files.WriteDefinitions(outDirectory, definitionFiles, result.Model, boardSlices); + if (definitions.Written + definitions.Merged > 0) + { + run.Out.WriteLine( + $"Wrote {definitions.Written} event model definition(s)" + + (definitions.Merged > 0 ? $", merged new slices or roles into {definitions.Merged}" : "") + + ". Register them with AddEventModel() (or let Wolverine discover them)."); + } + + var specsDirectory = Path.GetFullPath(input.SpecsOutFlag + ?? Path.Combine(Path.GetDirectoryName(outDirectory) ?? outDirectory, specsNamespace)); + writeSpecs(result.Model, specs, specsDirectory, files, input.ForceFlag, run); + + foreach (var line in files.Lines) + { + run.Out.WriteLine(line); + run.Report.Add(line); + } + + run.Warn(files.Callouts.Where(x => !x.Code.StartsWith("merged-", StringComparison.Ordinal))); + run.Callouts = run.Callouts.Concat(files.Callouts).ToList(); + + writeSpecProject(input, model, specsNamespace, outDirectory, specsDirectory, run); + + return result.Model.Model; + } + + private static void writeSpecs(ImportedEventModel model, GeneratedSpecs specs, string directory, ImportFileWriter files, bool force, + SpecRun run) + { + run.Report.AddRange(specs.Report); + foreach (var line in specs.Report) run.Out.WriteLine(line); + + Directory.CreateDirectory(directory); + var counts = new SpecFileWriter(files, force).WriteSpecs(directory, specs.Files); + run.Out.WriteLine( + $"Wrote {counts.Written} specification file(s) to {directory} ({specs.Specs} specification(s) in {specs.Features} feature(s) in all). " + + $"{EmlangSpecWriter.TestSupportFile} starts the application's own host through Alba."); + if (counts.Kept + counts.Merged == 0) return; + + // What the model has that the specifications there still don't, after the merge: an example whose + // specification could not be placed (each such file is a callout too) + var sources = Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories).Select(File.ReadAllText); + var missing = EmlangSpecWriter.MissingSpecs(model, sources); + + if (counts.Merged > 0) run.Out.WriteLine($"Merged new specifications into {counts.Merged} file(s) that already exist."); + if (counts.Kept > 0) run.Out.WriteLine($"Kept {counts.Kept} specification file(s) that already exist (--force overwrites them)."); + run.Out.WriteLine(missing.Count == 0 + ? "Every example in the model has a specification." + : $"{missing.Count} example(s) in the model have no specification:"); + foreach (var name in missing) run.Out.WriteLine($" {name}"); + } + + /// + /// The spec project around the specifications, when the directory has none, and the checks on the application. + /// Every decision is 's; nothing it names that already exists is written, + /// with or without --force. + /// + private static void writeSpecProject(WriteSpecsInput input, string model, string specsNamespace, string outDirectory, + string specsDirectory, SpecRun run) + { + var appProject = input.AppProjectFlag is { Length: > 0 } flag + ? Path.GetFullPath(flag) + : SpecProjectWriter.FindAppProject(outDirectory); + + var plan = SpecProjectWriter.Plan(new SpecProjectRequest(specsDirectory, specsNamespace, model, appProject, + input.BobcatVersionFlag, input.WolverineVersionFlag)); + + foreach (var file in plan.Files) + { + Directory.CreateDirectory(Path.GetDirectoryName(file.Path)!); + File.WriteAllText(file.Path, file.Content); // UTF-8 without a BOM + run.Files.Add(new ImportedFile(file.Path, "write")); + } + + foreach (var file in plan.Kept) run.Files.Add(new ImportedFile(file.Path, "kept")); + + run.Report.AddRange(plan.Report); + foreach (var line in plan.Report) run.Out.WriteLine(line); + + run.Warn(plan.Callouts); + run.Callouts = run.Callouts.Concat(plan.Callouts).ToList(); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.Http/WolverineHttpScenario.cs b/src/Bobcat/Wolverine.Bobcat.Http/WolverineHttpScenario.cs index 42a87f658..a314ea441 100644 --- a/src/Bobcat/Wolverine.Bobcat.Http/WolverineHttpScenario.cs +++ b/src/Bobcat/Wolverine.Bobcat.Http/WolverineHttpScenario.cs @@ -75,13 +75,13 @@ public IScenarioResult? LastResponse /// built before the act; the step shows only the members it specifies. /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http)] + [EventModelTrigger(ActTrigger.Http)] public Task WhenPosted(object command, Action? configure = null) => commandAsync("POST", command, null, null, configure); /// POST to . [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route))] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route))] public Task WhenPosted(object command, string route, Action? configure = null) => commandAsync("POST", command, route, null, configure); @@ -91,43 +91,43 @@ public Task WhenPosted(object command, string route, Action? configure /// command's properties of the same name. A constant route name reaches the Event Model as that endpoint's route. /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "POST")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "POST")] public Task WhenPostedToRoute(object command, string routeName, Action? configure = null) => commandAsync("POST", command, null, routeName, configure); /// PUT to the Wolverine.HTTP endpoint that accepts its type on PUT. [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, HttpMethod = "PUT")] + [EventModelTrigger(ActTrigger.Http, HttpMethod = "PUT")] public Task WhenPut(object command, Action? configure = null) => commandAsync("PUT", command, null, null, configure); /// PUT to . [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route), HttpMethod = "PUT")] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "PUT")] public Task WhenPut(object command, string route, Action? configure = null) => commandAsync("PUT", command, route, null, configure); /// PUT to the endpoint named , as for . [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "PUT")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "PUT")] public Task WhenPutToRoute(object command, string routeName, Action? configure = null) => commandAsync("PUT", command, null, routeName, configure); /// PATCH to the Wolverine.HTTP endpoint that accepts its type on PATCH. [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, HttpMethod = "PATCH")] + [EventModelTrigger(ActTrigger.Http, HttpMethod = "PATCH")] public Task WhenPatched(object command, Action? configure = null) => commandAsync("PATCH", command, null, null, configure); /// PATCH to . [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route), HttpMethod = "PATCH")] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "PATCH")] public Task WhenPatched(object command, string route, Action? configure = null) => commandAsync("PATCH", command, route, null, configure); /// PATCH to the endpoint named , as for . [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "PATCH")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "PATCH")] public Task WhenPatchedToRoute(object command, string routeName, Action? configure = null) => commandAsync("PATCH", command, null, routeName, configure); @@ -136,19 +136,19 @@ public Task WhenPatchedToRoute(object command, string routeName, Action [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, HttpMethod = "DELETE")] + [EventModelTrigger(ActTrigger.Http, HttpMethod = "DELETE")] public Task WhenDeleted(object command, Action? configure = null) => commandAsync("DELETE", command, null, null, configure); /// Send as the JSON body of a DELETE to . [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route), HttpMethod = "DELETE")] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "DELETE")] public Task WhenDeleted(object command, string route, Action? configure = null) => commandAsync("DELETE", command, route, null, configure); /// DELETE to the endpoint named with as the body, as for . [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "DELETE")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "DELETE")] public Task WhenDeletedToRoute(object command, string routeName, Action? configure = null) => commandAsync("DELETE", command, null, routeName, configure); @@ -164,7 +164,7 @@ public Task WhenDeletedToRoute(object command, string routeName, Action [EventModelRole(SliceRole.ReadModel, nameof(T))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route), HttpMethod = "GET")] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "GET")] public Task WhenReading(string route, params object[] routeValues) => httpRoot.readAsync(route, HttpRoutes.Fill(route, routeValues)); @@ -175,7 +175,7 @@ public Task WhenDeletedToRoute(object command, string routeName, Action [EventModelRole(SliceRole.ReadModel, nameof(T))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "GET")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "GET")] public Task WhenReadingRoute(string routeName, params object[] routeValues) { var template = HttpRoutes.Named(Host, routeName, "GET"); @@ -283,7 +283,7 @@ private async Task sendAsync(string verb, object? command, object? built, string /// response body, deserialized — or null when the endpoint refused or failed. /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http)] + [EventModelTrigger(ActTrigger.Http)] public async Task WhenPosted(object command, Action? configure = null) { await WhenPosted(command, configure); @@ -292,7 +292,7 @@ private async Task sendAsync(string verb, object? command, object? built, string /// POST to and hand back the 2xx response body. [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route))] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route))] public async Task WhenPosted(object command, string route, Action? configure = null) { await WhenPosted(command, route, configure); diff --git a/src/Bobcat/Wolverine.Bobcat.Http/WolverineHttpSpec.cs b/src/Bobcat/Wolverine.Bobcat.Http/WolverineHttpSpec.cs index 79898d24c..ba9202278 100644 --- a/src/Bobcat/Wolverine.Bobcat.Http/WolverineHttpSpec.cs +++ b/src/Bobcat/Wolverine.Bobcat.Http/WolverineHttpSpec.cs @@ -36,92 +36,92 @@ protected WolverineHttpSpec(WolverineHttpScenario scenario) : base(scenario) /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http)] + [EventModelTrigger(ActTrigger.Http)] public Task WhenPosted(object command, Action? configure = null) => HttpScenario.WhenPosted(command, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route))] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route))] public Task WhenPosted(object command, string route, Action? configure = null) => HttpScenario.WhenPosted(command, route, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http)] + [EventModelTrigger(ActTrigger.Http)] public Task WhenPosted(object command, Action? configure = null) => HttpScenario.WhenPosted(command, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route))] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route))] public Task WhenPosted(object command, string route, Action? configure = null) => HttpScenario.WhenPosted(command, route, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "POST")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "POST")] public Task WhenPostedToRoute(object command, string routeName, Action? configure = null) => HttpScenario.WhenPostedToRoute(command, routeName, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, HttpMethod = "PUT")] + [EventModelTrigger(ActTrigger.Http, HttpMethod = "PUT")] public Task WhenPut(object command, Action? configure = null) => HttpScenario.WhenPut(command, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route), HttpMethod = "PUT")] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "PUT")] public Task WhenPut(object command, string route, Action? configure = null) => HttpScenario.WhenPut(command, route, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "PUT")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "PUT")] public Task WhenPutToRoute(object command, string routeName, Action? configure = null) => HttpScenario.WhenPutToRoute(command, routeName, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, HttpMethod = "PATCH")] + [EventModelTrigger(ActTrigger.Http, HttpMethod = "PATCH")] public Task WhenPatched(object command, Action? configure = null) => HttpScenario.WhenPatched(command, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route), HttpMethod = "PATCH")] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "PATCH")] public Task WhenPatched(object command, string route, Action? configure = null) => HttpScenario.WhenPatched(command, route, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "PATCH")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "PATCH")] public Task WhenPatchedToRoute(object command, string routeName, Action? configure = null) => HttpScenario.WhenPatchedToRoute(command, routeName, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, HttpMethod = "DELETE")] + [EventModelTrigger(ActTrigger.Http, HttpMethod = "DELETE")] public Task WhenDeleted(object command, Action? configure = null) => HttpScenario.WhenDeleted(command, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route), HttpMethod = "DELETE")] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "DELETE")] public Task WhenDeleted(object command, string route, Action? configure = null) => HttpScenario.WhenDeleted(command, route, configure); /// [EventModelRole(SliceRole.Command, nameof(command))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "DELETE")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "DELETE")] public Task WhenDeletedToRoute(object command, string routeName, Action? configure = null) => HttpScenario.WhenDeletedToRoute(command, routeName, configure); /// [EventModelRole(SliceRole.ReadModel, nameof(T))] - [EventModelTrigger(TriggerKind.Http, Route = nameof(route), HttpMethod = "GET")] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "GET")] public Task WhenReading(string route, params object[] routeValues) => HttpScenario.WhenReading(route, routeValues); /// [EventModelRole(SliceRole.ReadModel, nameof(T))] - [EventModelTrigger(TriggerKind.Http, RouteName = nameof(routeName), HttpMethod = "GET")] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "GET")] public Task WhenReadingRoute(string routeName, params object[] routeValues) => HttpScenario.WhenReadingRoute(routeName, routeValues); diff --git a/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SliceIdentitySpecs.cs b/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SliceIdentitySpecs.cs new file mode 100644 index 000000000..2462441a3 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SliceIdentitySpecs.cs @@ -0,0 +1,65 @@ +using Bobcat; +using Bobcat.Xunit; + +namespace Wolverine.Bobcat.Tests.EventModel; + +/// +/// The projected twin of SliceIdentity.feature, moved here from Bobcat's acceptance suite with +/// (GH-5082): its slices are what +/// audits out of this assembly's real generated BobcatEventModelSource. Deliberately trivial: the subject under test is +/// the identities the generator emits, not the arithmetic. +/// +/// +/// The Gherkin lane's step-less scenario is a pending claim; here it is +/// [BobcatSpec(Pending = true)], which is skipped by design and still a claim on its slice. +/// +[BobcatFeature("Slice Identity")] +[BobcatSlice(SliceName = "CreditWallet", Domain = "Acceptance", Chapter = "Identity")] +public class SliceIdentitySpecs +{ + private int _balance; + private bool _refused; + + private void credit(int amount) + { + if (amount <= 0) + { + _refused = true; + return; + } + + _balance += amount; + } + + [Fact] + public void A_credited_wallet_shows_the_new_balance() + { + // Given a wallet with 100 + _balance = 100; + + // When 25 is credited + credit(25); + + // Then the balance should be 125 + _balance.ShouldBe(125); + } + + [Fact] + public void Crediting_nothing_is_refused() + { + // Given a wallet with 100 + _balance = 100; + + // When 0 is credited + credit(0); + + // Then the credit is refused + _refused.ShouldBeTrue(); + } + + [BobcatSpec(Pending = true)] + [BobcatSlice(SliceName = "WalletBalance", Domain = "Acceptance", Chapter = "Identity")] + public void The_balance_view_is_not_specified_yet() + { + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SpecIdentityAuditEndToEndTests.cs b/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SpecIdentityAuditEndToEndTests.cs new file mode 100644 index 000000000..d57e700d8 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SpecIdentityAuditEndToEndTests.cs @@ -0,0 +1,131 @@ +using Bobcat; +using JasperFx.Descriptors; +using JasperFx.Events.EventModeling; + +namespace Wolverine.Bobcat.Tests.EventModel; + +/// +/// Issue #338, end to end, from the projected lane: the identities this assembly's real generated +/// descriptor declares, audited against a design model. +/// +/// +/// The pure comparison is covered in . What only a compiled spec assembly can +/// prove is that the join sees what the generator emitted, through the internal generated type. +/// exists for this test and nothing else, and it declares the same +/// identities SliceIdentity.feature did, so the two lanes are held to one spelling. +/// +public class SpecIdentityAuditEndToEndTests +{ + private const string Credited = "Slice Identity/A credited wallet shows the new balance"; + private const string Refused = "Slice Identity/Crediting nothing is refused"; + private const string Unspecified = "Slice Identity/The balance view is not specified yet"; + + + private static IReadOnlyList compiled() + { + var descriptor = GeneratedEventModel.For(typeof(SpecIdentityAuditEndToEndTests).Assembly); + descriptor.ShouldNotBeNull(); + return SpecIdentityAudit.ClaimsIn(descriptor); + } + + /// Only the feature this test owns. + private static IReadOnlyList compiledHere() + => compiled().Where(x => x.Identity.StartsWith("Slice Identity/")).ToList(); + + /// A design model declaring exactly the identities named, on the slices named. + private static EventModelDescriptor design(params (string Slice, string Identity)[] scenarios) + => new("Acceptance", scenarios + .GroupBy(x => x.Slice) + .Select(group => new EventModelSliceDescriptor( + group.Key, null, null, null, null, + [], [], []) + { + Specifications = group.Select(x => new SpecificationDescriptor(x.Identity)).ToList(), + Hotspots = [] + }) + .ToList()); + + [Fact] + public void the_generated_descriptor_is_reachable_out_of_a_compiled_spec_assembly() + { + var claims = compiled(); + + // The two specified scenarios bind to the slice their tag names… + claims.ShouldContain(x => x.Identity == Credited && x.Slice == "CreditWallet" && !x.Pending); + claims.ShouldContain(x => x.Identity == Refused && x.Slice == "CreditWallet" && !x.Pending); + + // …and the step-less one is a claim too, marked pending. Leaving it out would report the + // scenario as a hole — "nothing verifies this" — when the test is right there, empty. + claims.ShouldContain(x => x.Identity == Unspecified && x.Slice == "WalletBalance" && x.Pending); + } + + [Fact] + public void a_model_that_declares_every_identity_has_no_drift() + { + var audit = SpecIdentityAudit.Compare( + SpecIdentityAudit.ClaimsIn(design( + ("CreditWallet", Credited), + ("CreditWallet", Refused), + ("WalletBalance", Unspecified))), + compiledHere()); + + audit.Drifted.ShouldBeFalse(); + audit.Matched.ShouldBe(3); + audit.Pending.ShouldHaveSingleItem().Identity.ShouldBe(Unspecified); + audit.Report().ShouldContain("3 matched, no drift"); + } + + [Fact] + public void a_scenario_nobody_designed_is_an_orphan() + { + // The drift that survived four commits: a spec added, the model not updated. + var audit = SpecIdentityAudit.Compare( + SpecIdentityAudit.ClaimsIn(design(("CreditWallet", Credited), ("WalletBalance", Unspecified))), + compiledHere()); + + audit.Drifted.ShouldBeTrue(); + audit.Orphans.ShouldHaveSingleItem().Identity.ShouldBe(Refused); + audit.Holes.ShouldBeEmpty(); + audit.Report().ShouldContain("Orphans"); + audit.Report().ShouldContain(Refused); + } + + [Fact] + public void a_designed_scenario_nothing_covers_is_a_hole() + { + var audit = SpecIdentityAudit.Compare( + SpecIdentityAudit.ClaimsIn(design( + ("CreditWallet", Credited), + ("CreditWallet", Refused), + ("WalletBalance", Unspecified), + ("CreditWallet", "Slice Identity/Crediting a frozen wallet is refused"))), + compiledHere()); + + audit.Drifted.ShouldBeTrue(); + audit.Holes.ShouldHaveSingleItem().Identity.ShouldBe("Slice Identity/Crediting a frozen wallet is refused"); + audit.Orphans.ShouldBeEmpty(); + } + + [Fact] + public void the_assembly_form_is_what_a_spec_project_calls() + { + // One line in the spec assembly's own test suite, which is the only place both facts are + // already loaded. `excused` names what is deliberately unbound, out loud. + // Bobcat's acceptance suite had no other slice-bound spec; this assembly does (the Wolverine vocabulary's own + // projected specs), so the design declares those too, exactly as compiled, and only Slice Identity is in question. + var others = compiled().Where(x => !x.Identity.StartsWith("Slice Identity/")).ToList(); + others.ShouldNotBeEmpty(); + + var audit = SpecIdentityAudit.Compare( + design([("CreditWallet", Credited), ("CreditWallet", Refused), ..others.Select(x => (x.Slice, x.Identity))]), + new Dictionary + { + [Unspecified] = "the balance view is not designed yet" + }, + typeof(SpecIdentityAuditEndToEndTests).Assembly); + + audit.Drifted.ShouldBeFalse(); + audit.Excused.Select(x => x.Key).ShouldBe([Unspecified]); + audit.Report().ShouldContain("the balance view is not designed yet"); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SpecIdentityAuditTests.cs b/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SpecIdentityAuditTests.cs new file mode 100644 index 000000000..4d340c61d --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.Tests/EventModel/SpecIdentityAuditTests.cs @@ -0,0 +1,189 @@ +using Bobcat; +using System.Reflection; +using JasperFx.Events.EventModeling; + +namespace Wolverine.Bobcat.Tests.EventModel; + +/// +/// bobcat#338: the comparison itself. The end-to-end half — reading the identities out of a +/// compiled spec assembly's internal generated type — is . +/// Both moved here from Bobcat with (GH-5082, JasperFx/bobcat#744). +/// +public class SpecIdentityAuditTests +{ + private static EventModelDescriptor model(params (string Slice, string Identity, bool Pending)[] claims) + => new("M", claims + .GroupBy(x => x.Slice) + .Select(group => new EventModelSliceDescriptor(group.Key, null, null, null, null, [], [], []) + { + Specifications = group.Where(x => !x.Pending) + .Select(x => new SpecificationDescriptor(x.Identity)).ToList(), + Hotspots = group.Where(x => x.Pending) + .Select(x => HotspotDescriptor.PendingSpecification(x.Identity)).ToList() + }) + .ToList()); + + private static (string, string, bool) on(string slice, string identity) => (slice, identity, false); + + [Fact] + public void identities_that_join_are_matched_and_nothing_is_reported() + { + var audit = SpecIdentityAudit.Compare( + model(on("CreditWallet", "Wallet/A wallet is credited")), + model(on("CreditWallet", "Wallet/A wallet is credited"))); + + audit.Drifted.ShouldBeFalse(); + audit.Matched.ShouldBe(1); + audit.Report().ShouldBe("Spec identities: 1 matched, no drift." + Environment.NewLine); + } + + [Fact] + public void a_test_identity_the_model_does_not_declare_is_an_orphan() + { + // The exact drift #338 was filed for: a spec added, the model left alone. Green suite, + // clean build, four commits. + var audit = SpecIdentityAudit.Compare( + model(on("CreditWallet", "Wallet/A wallet is credited")), + model( + on("CreditWallet", "Wallet/A wallet is credited"), + on("CreditWallet", "Wallet/A wallet nobody designed"))); + + audit.Drifted.ShouldBeTrue(); + audit.Matched.ShouldBe(1); + + var orphan = audit.Orphans.ShouldHaveSingleItem(); + orphan.Identity.ShouldBe("Wallet/A wallet nobody designed"); + orphan.Slice.ShouldBe("CreditWallet"); + + audit.Report().ShouldContain("a test declares this identity and the model does not"); + } + + [Fact] + public void a_model_scenario_no_test_covers_is_a_hole() + { + var audit = SpecIdentityAudit.Compare( + model( + on("CreditWallet", "Wallet/A wallet is credited"), + on("CreditWallet", "Wallet/A frozen wallet is refused")), + model(on("CreditWallet", "Wallet/A wallet is credited"))); + + audit.Drifted.ShouldBeTrue(); + audit.Holes.ShouldHaveSingleItem().Identity.ShouldBe("Wallet/A frozen wallet is refused"); + audit.Orphans.ShouldBeEmpty(); + audit.Report().ShouldContain("the model declares this identity and no test covers it"); + } + + [Fact] + public void the_two_directions_are_separate_findings() + { + // An orphan and a hole are not one discrepancy seen twice: one says the canvas shows a + // specification for something nobody designed, the other says the design declares + // behaviour nothing verifies. A count alone would call this suite even. + var audit = SpecIdentityAudit.Compare( + model(on("CreditWallet", "Wallet/A frozen wallet is refused")), + model(on("CreditWallet", "Wallet/A wallet nobody designed"))); + + audit.Orphans.ShouldHaveSingleItem(); + audit.Holes.ShouldHaveSingleItem(); + audit.Matched.ShouldBe(0); + } + + [Fact] + public void an_identity_bound_to_the_wrong_slice_is_misbound() + { + // Neither BOBCAT025 nor BOBCAT026 can see this: both compare slice NAMES, and both names + // here are real. The identity joins, and points at the wrong behaviour on the canvas. + var audit = SpecIdentityAudit.Compare( + model(on("CreditWallet", "Wallet/A wallet is credited")), + model(on("DebitWallet", "Wallet/A wallet is credited"))); + + audit.Drifted.ShouldBeTrue(); + + var misbound = audit.Misbound.ShouldHaveSingleItem(); + misbound.Identity.ShouldBe("Wallet/A wallet is credited"); + misbound.DeclaredOn.ShouldBe("CreditWallet"); + misbound.BoundTo.ShouldBe("DebitWallet"); + } + + [Fact] + public void several_features_may_feed_one_slice_without_being_misbound() + { + // A slice is a behaviour, not a document: three features describing CreditWallet is the + // shipped case (Wallet.feature tags it three times). Comparing the SET of slices per + // identity is what keeps that from reading as drift. + var declared = model( + on("CreditWallet", "Wallet/A wallet is credited"), + on("CreditWallet", "WalletHttp/A wallet is credited over HTTP")); + + var audit = SpecIdentityAudit.Compare(declared, declared); + + audit.Drifted.ShouldBeFalse(); + audit.Matched.ShouldBe(2); + } + + [Fact] + public void a_pending_specification_joins_and_is_not_drift() + { + // The identity is real and the scenario exists; it just has no steps, which is already a + // hotspot on the canvas (jasperfx#689). Reporting it as a hole would read as a missing + // test, and failing a build for it would make this audit an unrelated policy. + var audit = SpecIdentityAudit.Compare( + model(on("WalletBalance", "Wallet/The balance view")), + model(("WalletBalance", "Wallet/The balance view", true))); + + audit.Drifted.ShouldBeFalse(); + audit.Matched.ShouldBe(1); + audit.Pending.ShouldHaveSingleItem().Identity.ShouldBe("Wallet/The balance view"); + audit.Report().ShouldContain("the scenario has no steps"); + } + + [Fact] + public void an_excused_identity_is_left_out_of_both_directions_and_named_in_the_report() + { + // The escape hatch said out loud (#338): a chapter-wide invariant test that binds no + // slice, or a model scenario specified in a lane Bobcat cannot see. It has to carry a + // reason, because "deliberately not a spec" is a claim someone should have to make. + var audit = SpecIdentityAudit.Compare( + model(on("CreditWallet", "Wallet/Specified elsewhere")), + model(on("CreditWallet", "Wallet/Deliberately unbound")), + new Dictionary + { + ["Wallet/Specified elsewhere"] = "covered by the ledger invariant suite", + ["Wallet/Deliberately unbound"] = "a chapter-wide invariant, bound to no slice" + }); + + audit.Drifted.ShouldBeFalse(); + audit.Orphans.ShouldBeEmpty(); + audit.Holes.ShouldBeEmpty(); + audit.Report().ShouldContain("covered by the ledger invariant suite"); + } + + // In Bobcat this was the test assembly itself, which declared no slices. This one does (SliceIdentitySpecs and the + // vocabulary's own projected specs), so the assembly with no generated source is WolverineFx.Bobcat: it is compiled + // without Bobcat's generator. + private static readonly Assembly noGeneratedSource = typeof(SpecIdentityAudit).Assembly; + + [Fact] + public void auditing_an_assembly_with_no_generated_source_refuses_rather_than_reporting_holes() + { + // Silence would be a confident lie: every declared scenario would come back uncovered, + // which is the same failure as zero-filling an unmeasured duration. + var refusal = Should.Throw(() => SpecIdentityAudit.Compare( + model(on("CreditWallet", "Wallet/A wallet is credited")), + noGeneratedSource)); + + refusal.Message.ShouldContain("has a generated"); + // The Gherkin lane and its @slice: tag left in 0.50.0 (bobcat#660): name what binds a slice now. + refusal.Message.ShouldContain("[BobcatSpec(typeof(TheCommand))]"); + refusal.Message.ShouldNotContain("@slice:"); + } + + [Fact] + public void an_assembly_with_no_generated_event_model_reads_as_null_not_as_a_failure() + { + // An assembly whose specs declare no slices legitimately has no generated source. That is a + // fact for the caller to interpret, not an error. + GeneratedEventModel.For(noGeneratedSource).ShouldBeNull(); + GeneratedEventModel.For(typeof(Assembly).Assembly).ShouldBeNull(); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat.Tests/critter_stack_spec_roles.cs b/src/Bobcat/Wolverine.Bobcat.Tests/critter_stack_spec_roles.cs new file mode 100644 index 000000000..1b1f025b6 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat.Tests/critter_stack_spec_roles.cs @@ -0,0 +1,204 @@ +using System.Reflection; +using Bobcat; +using Bobcat.CritterStack; +using JasperFx.Events.EventModeling; + +// bobcat#471, moved here from Bobcat.Generators.Tests with the vocabulary (GH-5082, JasperFx/bobcat#744). +// Bobcat compiled the specs below in a generator harness; here they are this assembly's own projected specs, +// so the descriptor is the real one Bobcat's generator emitted into this build, read through GeneratedEventModel. +// They are [Fact(Explicit = true)]: they exist to be compiled, and a null service provider never runs. + +namespace Wolverine.Bobcat.Tests.CritterStackRoles.Bank +{ + public record AccountOpened(Guid Id, string Owner); + public record FundsDeposited(Guid Id, decimal Amount); + public record FundsWithdrawn(Guid Id, decimal Amount); + public record DepositReceipt(Guid Id); + public class Account { public Guid Id { get; set; } } + public class AccountSummary { public Guid Id { get; set; } } + public class Statement { public Guid Id { get; set; } } + public record ClientEnrolled(Guid Id, string Name); + public class Client { public Guid Id { get; set; } } +} + +namespace Wolverine.Bobcat.Tests.CritterStackRoles.Specs +{ + using Bank; + + [BobcatFeature("Accounts")] + public class account_specs : CritterStackSpec + { + public account_specs() : base((IServiceProvider)null!) { } + + [Fact(Explicit = true), BobcatSlice(SliceName = "Deposit")] + public async Task a_deposit_is_credited() + { + var id = Guid.NewGuid(); + await GivenEvents(id, new AccountOpened(id, "Ann")); + await WhenActing("a deposit is made", _ => Task.CompletedTask); + ThenEvents(typeof(FundsDeposited)); + await ThenAggregate(id); + await ThenDocument(id); + ThenMessagesSent(); + + // Negatives stamp nothing + ThenNotEmitted(); + } + + [Fact(Explicit = true), BobcatSlice(SliceName = "Withdraw")] + public async Task a_withdrawal_acting_on_the_stream() + { + var id = Guid.NewGuid(); + await GivenEvents(typeof(Account), id, new AccountOpened(id, "Ann")); + await WhenActingOn(id, "a withdrawal", _ => Task.CompletedTask, new FundsWithdrawn(id, 1m)); + } + + [Fact(Explicit = true), BobcatSlice(SliceName = "OpenAccount")] + public async Task an_account_is_opened_for_a_known_client() + { + var client = Guid.NewGuid(); + await GivenContext(client, new ClientEnrolled(client, "Ann")); + await WhenActing("an account is opened", _ => Task.CompletedTask); + await ThenAggregate(Guid.NewGuid()); + } + + [Fact(Explicit = true), BobcatSlice(SliceName = "CloseAccount", Pattern = "Command")] + public async Task an_account_is_closed_for_a_stored_client() + { + await GivenReadModel(new Client()); + await WhenActing("an account is closed", _ => Task.CompletedTask); + } + } + + [BobcatFeature("Summaries")] + [BobcatSlice(SliceType = typeof(AccountSummary))] // BOBCAT024 prefers the type; the slice is still "AccountSummary" + public class account_summary : CritterStackSpec + { + public account_summary() : base((IServiceProvider)null!) { } + + [Fact(Explicit = true)] + public async Task the_summary_shows_the_balance() + { + var id = Guid.NewGuid(); + await GivenEventsOn(id, new AccountOpened(id, "Ann"), new FundsDeposited(id, 10m)); + await ThenReadModel(id); + await ThenSingleDocument(); + } + } +} + +namespace Wolverine.Bobcat.Tests.CritterStackRoles +{ + /// + /// bobcat#471: the store-only and its + /// forwarding twins carry [EventModelRole], so a projected spec written against the real vocabulary — + /// read from WolverineFx.Bobcat's metadata, as a consumer's build reads it — reaches the Event Model with + /// its aggregates, events, arranged events, read models, documents and messages. + /// + public class CritterStackSpecRoleTests + { + [Fact] + public void a_projected_critter_stack_spec_reaches_the_event_model_with_its_roles() + { + var slices = GeneratedEventModel.For(typeof(CritterStackSpecRoleTests).Assembly)!.Slices; + + var deposit = slices.Single(x => x.Name == "Deposit"); + deposit.CommandType.ShouldBeNull(); + names(deposit.AggregateTypes).ShouldBe(["Account"]); + names(deposit.EmittedEvents).ShouldBe(["FundsDeposited"]); + names(deposit.PublishedMessages).ShouldBe(["DepositReceipt"]); + names(deposit.ReadModelTypes).ShouldBeEmpty(); + names(deposit.Specifications.ShouldHaveSingleItem().ResolvedTypes) + .ShouldBe(["Account", "AccountOpened", "FundsDeposited", "Statement", "DepositReceipt"]); + + var withdraw = slices.Single(x => x.Name == "Withdraw"); + names(withdraw.AggregateTypes).ShouldBe(["Account"]); + names(withdraw.EmittedEvents).ShouldBe(["FundsWithdrawn"]); + names(withdraw.Specifications.ShouldHaveSingleItem().ResolvedTypes) + .ShouldBe(["Account", "AccountOpened", "FundsWithdrawn"]); + + // bobcat#502: the arranged lookup is read from, and only the stream the act decides against is an aggregate + var open = slices.Single(x => x.Name == "OpenAccount"); + names(open.ReadsFrom).ShouldBe(["Client"]); + names(open.AggregateTypes).ShouldBe(["Account"]); + names(open.Specifications.ShouldHaveSingleItem().ResolvedTypes).ShouldBe(["Client", "ClientEnrolled", "Account"]); + names(deposit.ReadsFrom).ShouldBeEmpty(); + + // bobcat#614: a document stored before a command is a lookup the command reads, not a view it produces + var close = slices.Single(x => x.Name == "CloseAccount"); + names(close.ReadsFrom).ShouldBe(["Client"]); + close.ReadModelTypes.ShouldBeEmpty(); + + var summary = slices.Single(x => x.Name == "AccountSummary"); + summary.Pattern.ShouldBe(SlicePattern.View); + names(summary.AggregateTypes).ShouldBe(["Account"]); + names(summary.ReadModelTypes).ShouldBe(["AccountSummary"]); + names(summary.ConsumedEvents).ShouldBe(["AccountOpened", "FundsDeposited"]); + + // A document is resolved, never a slot on the slice + names(summary.Specifications.ShouldHaveSingleItem().ResolvedTypes).ShouldContain("Statement"); + } + + [Fact] + public void every_spec_twin_declares_the_same_roles_as_the_scenario_method_it_forwards_to() + { + var scenario = rolesByMethod(typeof(CritterStackScenario)); + var spec = rolesByMethod(typeof(CritterStackSpec)); + + spec.ShouldNotBeEmpty(); + foreach (var (signature, roles) in spec) + { + scenario.Keys.ShouldContain(signature); + scenario[signature].ShouldBe(roles, $"the roles on CritterStackSpec.{signature}"); + } + + // The only role-bearing scenario overloads without a Spec twin + scenario.Keys.Except(spec.Keys).Order().ShouldBe([ + "GivenEventsOn(Type, Object, Object[])", + "ThenStreamIsStarted(Type, Object)" + ]); + } + + [Fact] + public void every_act_that_names_no_command_is_declared_an_act_on_both_twins() + { + // bobcat#615: WhenActing runs a lambda, so without [EventModelAct] its slice would read as a View + string[] acts(Type type) => type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(m => m.GetCustomAttribute() is not null) + .Select(signatureOf).Order().ToArray(); + + acts(typeof(CritterStackScenario)).ShouldBe(acts(typeof(CritterStackSpec))); + acts(typeof(CritterStackSpec)).Select(x => x.Split('(')[0]).Distinct().ShouldBe(["WhenActing", "WhenActingOn"]); + } + + [Fact] + public void every_role_names_a_parameter_of_its_method() + { + // BOBCAT034 runs on a consumer's source, not on Bobcat's own; nameof already keeps these honest + foreach (var type in new[] { typeof(CritterStackScenario), typeof(CritterStackSpec) }) + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + foreach (var role in method.GetCustomAttributes()) + { + var names = method.GetParameters().Select(x => x.Name) + .Concat(method.IsGenericMethod ? method.GetGenericArguments().Select(x => x.Name) : []); + names.ShouldContain(role.From, $"{type.Name}.{method.Name}"); + } + } + + private static Dictionary rolesByMethod(Type type) + => type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Select(m => (Method: m, Roles: m.GetCustomAttributes() + .Select(r => $"{r.Role}:{r.From}").Order().ToArray())) + .Where(x => x.Roles.Length > 0) + .ToDictionary(x => signatureOf(x.Method), x => x.Roles); + + private static string signatureOf(MethodInfo method) + { + var generic = method.IsGenericMethod ? $"<{string.Join(", ", method.GetGenericArguments().Select(x => x.Name))}>" : ""; + return $"{method.Name}{generic}({string.Join(", ", method.GetParameters().Select(p => p.ParameterType.Name))})"; + } + + private static string[] names(IEnumerable types) + => types.Select(x => x.Name).ToArray(); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/ActTimedOutException.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/ActTimedOutException.cs new file mode 100644 index 000000000..aaa648de7 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/ActTimedOutException.cs @@ -0,0 +1,27 @@ +namespace Bobcat.CritterStack; + +/// +/// bobcat#673. The act ran out of time — its own code, or the harness waiting on it (a tracked session that timed out +/// before everything the act caused had settled), threw a . Recorded on the act's own +/// When step, which reads error, so a reader starts at the act rather than at the Then steps that found +/// nothing to judge. The timeout the act raised is the , and stays on +/// exactly as it was. +/// +/// +/// Never thrown: the scenario carries on to its Then steps, as it does after any act that failed. A +/// , so code that caught the timeout before still catches this one. +/// +public sealed class ActTimedOutException : TimeoutException +{ + public ActTimedOutException(TimeoutException timeout, string? hint = null) + : base(describe(timeout, hint), timeout) + { + Hint = hint; + } + + /// What a derived scenario added about why its act may have run out of time, or null. + public string? Hint { get; } + + private static string describe(TimeoutException timeout, string? hint) + => $"The act timed out: {timeout.Message}" + (string.IsNullOrWhiteSpace(hint) ? "" : $" {hint}"); +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackActOutcome.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackActOutcome.cs new file mode 100644 index 000000000..a9e004356 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackActOutcome.cs @@ -0,0 +1,82 @@ +using JasperFx.Events; + +namespace Bobcat.CritterStack; + +/// +/// What one act of a did: the events it appended, the streams it started, +/// and how it failed or was refused (bobcat#471). +/// +/// +/// +/// Store terms only. Everything here can be learned from the event store, so an application that uses +/// Marten, Polecat or Fisher without a message bus gets the whole assertion vocabulary. +/// fills it from the store's high-water mark before and after +/// the act. A scenario that knows more about an act — WolverineFx.Bobcat's, through Wolverine's tracked +/// session — derives from this record and fills it from what it saw instead. +/// +/// +/// +/// The events the act itself appended, in commit order. For a store-only act that is every event appended +/// while it ran on a stream the specification arranged, addressed or started (bobcat#503) — the rest are +/// ; a scenario that can tell an act's own work from its cascades' puts only the +/// act's own here. +/// +/// The exception the act raised, when it raised one. Captured, never rethrown. +/// A refusal that is an answer rather than a failure, such as an HTTP endpoint's 4xx body. +public record CritterStackActOutcome( + IReadOnlyList NewEvents, + Exception? Error, + string? Refusal = null) +{ + /// An empty : nothing to tell apart. Declared before , which reads it. + public static readonly IReadOnlyDictionary NoStores = + new Dictionary(ReferenceEqualityComparer.Instance); + + /// Nothing has acted yet. + public static readonly CritterStackActOutcome None = new(Array.Empty(), null); + + /// + /// Every event appended while the act ran, on any stream, in commit order — its cascades' included, + /// where the scenario can see them. What a stream already held never counts. + /// + public IReadOnlyList AllNewEvents { get; init; } = Array.Empty(); + + /// + /// bobcat#503. Events appended while a store-only act ran on streams that already existed and that the + /// specification never arranged, addressed or started — most likely another test writing to the same store. + /// Never judged by a whole-store assertion (ThenEvents, ThenNoEvents, …); a failing one says + /// "cross-talk suspected" when this is not empty. A step that names a stream or a tag query still sees the + /// ones it addresses. Always empty for a scenario that tracks its act, such as WolverineFx.Bobcat's. + /// + /// + /// A stream another test started during the act cannot be told from one the act started, so its + /// events are in , not here. + /// + public IReadOnlyList ForeignEvents { get; init; } = Array.Empty(); + + /// The streams the act itself started, with the ids it assigned. + public IReadOnlyList StartedStreams { get; init; } = Array.Empty(); + + /// + /// Every message the act caused to be sent, cascades included. Always empty for a store-only act: + /// nothing tracked the messages. A scenario backed by a message bus fills it. + /// + public IReadOnlyList MessagesSent { get; init; } = Array.Empty(); + + /// + /// Whether the act's events carry the Dynamic Consistency Boundary tags they were appended with. False + /// for a store-only act: its events are read back from the store, and no store reads tags back, so a + /// step that judges tags says so rather than reporting every tagged event missing. + /// + public bool EventTagsKnown { get; init; } = true; + + /// + /// bobcat#504. Which event store each event came from, when the act was observed on more than one — every + /// event in and , by reference. Empty when there was + /// only one store to observe, and for a scenario that cannot tell (a tracked session that heard the commit). + /// + public IReadOnlyDictionary StoreByEvent { get; init; } = NoStores; + + /// bobcat#504. The store came from, or null when does not say. + public IEventStore? StoreOf(IEvent @event) => StoreByEvent.TryGetValue(@event, out var store) ? store : null; +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackHostExtensions.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackHostExtensions.cs new file mode 100644 index 000000000..eef894519 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackHostExtensions.cs @@ -0,0 +1,253 @@ +using JasperFx.CommandLine.Descriptions; +using JasperFx.Events; +using JasperFx.Events.Daemon; +using JasperFx.Resources; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bobcat.CritterStack; + +/// +/// Host-level Critter Stack helpers, hanging off the / +/// so a fixture's setup, a host resource's reset hook or a test's +/// arrange can use them without any scenario scope. +/// +/// +/// Marten (AddMarten), Polecat (AddPolecat) and Fisher (AddFisher) all +/// register their store as , so +/// is how a spec project finds the store without referencing the store's package. +/// +public static class CritterStackHostExtensions +{ + /// Every event store registered in the container, in registration order. + public static IReadOnlyList EventStores(this IServiceProvider services) + => services.GetServices().ToList(); + + /// + /// The host's default event store: the one registered in the container; or, when there are several, the + /// one whose name matches ; or, with no + /// , the one main store among them (), so an + /// application with one main store and any number of ancillary ones (Marten's AddMartenStore<T>, + /// Fisher's AddFisherStore<T>, an embedded CritterWatch console's own store) needs no name. + /// + /// + /// No store is registered; or several are, is null and there is not exactly one + /// main store among them; or no store has that name. The message lists the stores and their roles. + /// + public static IEventStore EventStore(this IServiceProvider services, string? storeName = null) + { + var stores = services.EventStores(); + + if (stores.Count == 0) + { + throw new InvalidOperationException( + "No JasperFx.Events.IEventStore is registered in the host's container. Marten (AddMarten), Polecat " + + "(AddPolecat) and Fisher (AddFisher) all register one — is the store wired into this host?"); + } + + if (storeName == null) + { + return DefaultEventStore(services, stores) ?? throw new InvalidOperationException(noDefault(services, stores)); + } + + return stores.FirstOrDefault(s => matches(s, storeName)) + ?? throw new InvalidOperationException( + $"No event store named '{storeName}' is registered. Known: " + + string.Join(", ", stores.Select(Bobcat.CritterStack.EventStores.Describe)) + "."); + } + + /// + /// The event stores registered as an application's main store — AddMarten, AddPolecat, + /// AddFisher — rather than as an ancillary store under a marker interface (AddMartenStore<T>, + /// AddPolecatStore<T>, AddFisherStore<T>), in registration order. + /// + /// + /// + /// Read from the container, store-agnostically: a main store is the one its own product's + /// IDocumentStore service resolves to. All three stores register their main store under their + /// IDocumentStore interface and an ancillary store under its marker interface only, so for an + /// ancillary store that service resolves to another instance (the main store) or to nothing. The interface + /// is found by name on the store's own type, so Bobcat references none of the stores. + /// + /// + /// Not the name: "main" is only the default StoreName, which + /// an application may change on its main store or give to an ancillary one, while the registration is what + /// makes a store the one IDocumentStore means. A store with no IDocumentStore interface at all + /// (a hand-written or in-memory store) is never a main store, so a host made only of those keeps asking for a + /// storeName. + /// + /// + public static IReadOnlyList MainEventStores(this IServiceProvider services) + => services.EventStores().Where(store => IsMainEventStore(services, store)).ToList(); + + /// + /// Whether is registered as the application's main store, rather than as an ancillary + /// one. See . + /// + public static bool IsMainEventStore(this IServiceProvider services, IEventStore store) + { + ArgumentNullException.ThrowIfNull(store); + + foreach (var service in store.GetType().GetInterfaces()) + { + if (service.IsGenericType || service.Name != MainStoreService) continue; + + object? registered; + try + { + registered = services.GetService(service); + } + catch (Exception) + { + continue; + } + + if (ReferenceEquals(registered, store)) return true; + } + + return false; + } + + // The service every Critter Stack store registers its main store under: Marten.IDocumentStore, + // Polecat.IDocumentStore, Fisher.IDocumentStore + private const string MainStoreService = "IDocumentStore"; + + /// + /// The default store with no name to go by: the only store, else the only main store, else null. + /// + internal static IEventStore? DefaultEventStore(IServiceProvider services, IReadOnlyList stores) + { + if (stores.Count == 1) return stores[0]; + + var mains = stores.Where(store => IsMainEventStore(services, store)).ToList(); + return mains.Count == 1 ? mains[0] : null; + } + + private static string noDefault(IServiceProvider services, IReadOnlyList stores) + { + var mains = stores.Where(store => IsMainEventStore(services, store)).ToList(); + var described = string.Join(", ", stores.Select(store => + Bobcat.CritterStack.EventStores.Describe(store) + (mains.Contains(store) ? " [main]" : " [ancillary]"))); + + var why = mains.Count == 0 + ? "none of them is registered as the application's main store (AddMarten, AddPolecat, AddFisher)" + : $"{mains.Count} of them are registered as a main store"; + + return $"The host registers {stores.Count} event stores ({described}) and {why}, so there is no default store. " + + "Pass storeName (the store's IEventStore.Identity name) to the scenario or spec to choose the default, " + + "or address a store per step with In() for a store registered under a marker interface or " + + "In(\"name\") by its name."; + } + + /// The coordinator driving the host's projection daemon, when the host runs one. + public static IProjectionCoordinator? ProjectionCoordinator(this IServiceProvider services) + => services.GetService(); + + /// + /// Every projection coordinator the host runs: the main store's, registered as + /// , and an ancillary store's, which Marten and Fisher register + /// only under their own IProjectionCoordinator<T> and as an . + /// Each appears once, however many registrations reach it. + /// + public static IReadOnlyList ProjectionCoordinators(this IServiceProvider services) + { + var coordinators = new List(); + var seen = new HashSet(ReferenceEqualityComparer.Instance); + var candidates = services.GetServices() + .Concat(services.GetServices().OfType()); + + foreach (var coordinator in candidates) + { + if (seen.Add(coordinator)) coordinators.Add(coordinator); + } + + return coordinators; + } + + /// + /// Delete every event and every document in every event store the host registers, keeping the + /// schema — the between-scenario reset. Pass this as a host resource's reset hook: + /// new WebApp(reset: host => host.ResetEventStoresAsync()). + /// + /// + /// bobcat#680: every projection coordinator is paused around the delete and resumed afterwards, as + /// Marten's own ResetAllMartenDataAsync does. Truncating under a running async daemon left its + /// progression ahead of the emptied store, and the daemon logged a + /// ProgressionProgressOutOfOrderException per reset. The coordinators are resumed even when a + /// reset throws, so a failed reset does not leave the host's projections stopped. + /// + public static async Task ResetEventStoresAsync(this IServiceProvider services, CancellationToken token = default) + { + var coordinators = services.ProjectionCoordinators(); + foreach (var coordinator in coordinators) + await coordinator.PauseAsync().ConfigureAwait(false); + + try + { + foreach (var store in services.EventStores()) + await Bobcat.CritterStack.EventStores.ResetAllDataAsync(store, token).ConfigureAwait(false); + } + finally + { + foreach (var coordinator in coordinators) + await coordinator.ResumeAsync().ConfigureAwait(false); + } + } + + /// + public static Task ResetEventStoresAsync(this IHost host, CancellationToken token = default) + => host.Services.ResetEventStoresAsync(token); + + /// + /// Run on every stateful resource the host's + /// s describe — Wolverine's envelope storage and transports, and any store + /// whose database supports rewindable state. This is what jasperfx resources clear does, + /// minus the schema Setup that command also runs per resource. It does not replace + /// : Marten 9.22's database + /// resource reports no rewindable state, so its ClearState is a no-op. + /// + public static async Task ClearStatefulResourcesAsync(this IServiceProvider services, CancellationToken token = default) + { + foreach (var part in services.GetServices()) + { + foreach (var resource in await part.FindResources().ConfigureAwait(false)) + await resource.ClearState(token).ConfigureAwait(false); + } + } + + /// + public static Task ClearStatefulResourcesAsync(this IHost host, CancellationToken token = default) + => host.Services.ClearStatefulResourcesAsync(token); + + /// + /// Wait for every async projection on the named (or only) store to catch up with its high-water + /// mark. See . + /// + public static Task WaitForNonStaleProjectionsAsync( + this IServiceProvider services, + TimeSpan? timeout = null, + string? storeName = null, + CancellationToken token = default) + => Bobcat.CritterStack.EventStores.WaitForNonStaleProjectionsAsync( + services.EventStore(storeName), timeout, services.ProjectionCoordinator(), token); + + /// + public static Task WaitForNonStaleProjectionsAsync( + this IHost host, + TimeSpan? timeout = null, + string? storeName = null, + CancellationToken token = default) + => host.Services.WaitForNonStaleProjectionsAsync(timeout, storeName, token); + + private static bool matches(IEventStore store, string storeName) + { + try + { + return string.Equals(store.Identity.Name, storeName, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackScenario.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackScenario.cs new file mode 100644 index 000000000..f672a3230 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackScenario.cs @@ -0,0 +1,2769 @@ +using System.Globalization; +using Bobcat.Engine; +using Bobcat.Runtime; +using JasperFx.Events; +using JasperFx.Events.Descriptors; +using JasperFx.Events.Tags; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bobcat.CritterStack; + +/// +/// The Given/When/Then vocabulary for an event-sourced application on Marten, Polecat or Fisher, over one +/// host and with no message bus (bobcat#471): arrange events and read models, act through the application's +/// own services, and assert what the act appended, what the aggregates and read models became, and how the +/// act was refused. +/// +/// +/// +/// Store-only on purpose. Plenty of applications use Marten without Wolverine, so nothing here needs +/// one. Every step reaches the store through the JasperFx.Events abstractions — the same scenario runs +/// against Marten, Polecat, Fisher or the in-memory store. WolverineFx.Bobcat's WolverineScenario +/// derives from this class and adds the acts that go through Wolverine's tracked session and the message +/// assertions; everything a store can answer lives here. +/// +/// +/// Every step renders. Each one records itself through — the model a +/// projected Bobcat spec renders from — and reports its verdict as cells. Inside a recording scenario a wrong +/// is gathered on its step and the test carries on; outside one, the wrong is thrown +/// (), so a plain test cannot pass by accident. +/// +/// +/// Run evidence. The types a scenario observably touched — the aggregates and events it arranged, the +/// events its acts appended, the read models it loaded — are recorded through +/// and travel on scenario_finished.TouchedTypes +/// (bobcat#465). Observed, never asserted: an event a Then step expects is not recorded. +/// +/// +public class CritterStackScenario +{ + /// + /// How a refusal's reason is compared with the text the act raised. One place, so the case rule is a + /// one-line change (bobcat#471 left exact-vs-ignore-case open). + /// + private const StringComparison ReasonComparison = StringComparison.Ordinal; + + private readonly string? _storeName; + private readonly IHost? _host; + private IEventStore? _store; + private bool? _hasStore; + + // bobcat#504: what one specification accumulates, shared with every view In() hands out, so a step through a + // view arranges, acts and asserts on the same specification. A view differs only in the store it is pinned to + private readonly SpecificationState _state; + private readonly CritterStackScenario? _root; + private readonly IEventStore? _pinned; + + // bobcat#536: the tenant a view from ForTenant reads and writes as; null for the store's default session + private readonly string? _tenant; + + private sealed class SpecificationState + { + public object? Stream; + public CritterStackActOutcome LastAct = CritterStackActOutcome.None; + public bool Acted; + + // bobcat#503: the streams this specification owns — arranged, addressed, or written by one of its acts — and + // the tags it arranged events with. A store-only act attributes what it read back by these + public readonly HashSet OwnStreams = new(); + public readonly HashSet ArrangedTags = new(); + + public TimeSpan ProjectionTimeout = TimeSpan.FromSeconds(20); + public IUnspecifiedValues? UnspecifiedValues; + + // bobcat#504: the host's stores, read once, and the routing between them when there are several + public IReadOnlyList? Stores; + public StoreRouter? Router; + + // The stores this specification has touched: a step went to it, a view was pinned to it, or an act appended + // the specification's own events to it. With the default store, what AfterAsyncDaemonsCatchUpAsync waits on + public readonly HashSet Touched = new(ReferenceEqualityComparer.Instance); + } + + /// The application under test. + /// + /// The default event store, when the application has several: what a step uses when no store's registrations + /// claim its types (bobcat#504). Null takes the only one, or the one main store among several (an + /// application's AddMarten store beside its ancillary AddMartenStore<T> ones). + /// + public CritterStackScenario(IHost host, string? storeName = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + Services = host.Services; + _storeName = storeName; + _state = new SpecificationState(); + } + + /// + /// A scenario over an application's services alone, for a test that has a container but no + /// — a hand-built ServiceProvider, or a host's Services handed on. + /// throws on a scenario built this way. + /// + /// The application's services. Its event store is resolved from them. + /// + /// The default event store, when the application has several: what a step uses when no store's registrations + /// claim its types (bobcat#504). Null takes the only one, or the one main store among several. + /// + public CritterStackScenario(IServiceProvider services, string? storeName = null) + { + Services = services ?? throw new ArgumentNullException(nameof(services)); + _storeName = storeName; + _state = new SpecificationState(); + } + + /// + /// bobcat#504. A view of 's specification pinned to : every + /// Given and Then reads and writes that store, while the specification — its streams, its last act, its + /// settings — stays 's, and an act through the view runs through + /// 's . What builds; a derived + /// scenario that overrides it to hand out its own view type calls this. + /// + protected CritterStackScenario(CritterStackScenario root, IEventStore store) + { + ArgumentNullException.ThrowIfNull(root); + _root = root._root ?? root; + _pinned = store ?? throw new ArgumentNullException(nameof(store)); + _host = root._host; + Services = root.Services; + _storeName = root._storeName; + _state = root._state; + _tenant = root._tenant; + } + + /// + /// bobcat#536. A view of 's specification for one tenant: every Given and Then reads and + /// writes as , on the store is pinned to (or routed to, as + /// before), while the specification stays 's. What + /// builds; a derived scenario that overrides it to hand out its own view type calls this. + /// + protected CritterStackScenario(CritterStackScenario root, string tenantId) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + _root = root._root ?? root; + _pinned = root._pinned; + _host = root._host; + Services = root.Services; + _storeName = root._storeName; + _state = root._state; + _tenant = tenantId; + } + + /// + /// The application under test. Throws when the scenario was built from an + /// rather than a host; every step reads instead, so only a derived scenario that + /// needs the host itself reaches for this. + /// + /// The scenario was built from an . + public IHost Host => _host ?? throw new InvalidOperationException( + "This scenario was built from an IServiceProvider, so it has no IHost. Read Services instead, or build the scenario from the host."); + + /// The application's services: the host's, or the provider the scenario was built from. + public IServiceProvider Services { get; } + + /// + /// How long a step that reads a projection waits for the async daemons to catch up. Twenty seconds by + /// default — what a cold daemon on a loaded CI runner needs. + /// + public TimeSpan ProjectionTimeout + { + get => _state.ProjectionTimeout; + set => _state.ProjectionTimeout = value; + } + + /// + /// What fills the members a partial object (Specify<T>() or a table row) leaves + /// unspecified when the scenario builds it for a Given or a When. Null takes Bobcat's default + /// (bobcat#421). + /// + public IUnspecifiedValues? UnspecifiedValues + { + get => _state.UnspecifiedValues; + set => _state.UnspecifiedValues = value; + } + + /// What the last act did. + public CritterStackActOutcome LastAct + { + get => _state.LastAct; + protected set + { + _state.LastAct = value ?? CritterStackActOutcome.None; + _state.Acted = !ReferenceEquals(_state.LastAct, CritterStackActOutcome.None); + recordTimedOutAct(_state.LastAct); + } + } + + /// + /// bobcat#673. An act that timed out fails its own When step, as an error naming the timeout. Every act sets + /// while its When step is open — this scenario's and a derived one's (a tracked session's + /// timeout) alike — so the step open now is the act's. Any other failure stays captured on the outcome only, for + /// a Then step to judge: a refusal that throws is what asserts, and a red + /// When there would fail the specification that expects it. A timeout is nothing a specification expects. + /// + private void recordTimedOutAct(CritterStackActOutcome outcome) + { + if (outcome.Error is not { } error) return; + if (exceptionsIn(error).OfType().FirstOrDefault() is not { } timeout) return; + if (ScenarioRecorder.CurrentStep is not { Failure: null } step) return; + + step.Failure = timeout as ActTimedOutException ?? new ActTimedOutException(timeout, actTimeoutHint(timeout)); + } + + /// + /// bobcat#673. A sentence on why an act of this scenario may have run out of time, appended to the + /// its When step carries, or null for none. A derived scenario whose act runs + /// under a timeout of its own says which setting raises it, and what commonly costs the time. + /// + protected virtual string? actTimeoutHint(TimeoutException timeout) => null; + + /// + /// The application's event store — the default one, when it has several (the constructor's storeName, + /// else the one main store among them, see ), or the + /// one a view from / is pinned to. Throws, saying so, when + /// it has none, or several and no default. + /// + /// + /// Since bobcat#504 the steps do not all use it: on a host with several stores each step goes to the store + /// whose registrations claim its types, and only falls back to this one. + /// + public IEventStore EventStore => _pinned ?? (_store ??= Services.EventStore(_storeName)); + + /// Whether the application has an event store at all. An application with none can still act. + protected bool hasStore() + { + if (_pinned is not null) return true; + if (_hasStore is { } known) return known; + + try + { + _store ??= Services.EventStore(_storeName); + _hasStore = true; + } + catch (Exception) + { + _hasStore = false; + } + + return _hasStore.Value; + } + + // ---- several stores (bobcat#504) -------------------------------------------------------- + + /// + /// bobcat#504. The same Given/Then vocabulary against the event store registered under the marker interface + /// — Marten's AddMartenStore<IInvoicingStore>(), Polecat's + /// AddPolecatStore<T>(), Fisher's AddFisherStore<T>(). The view shares this + /// specification: what it arranges is this scenario's, an act through it is this scenario's act, and every + /// step renders in the same specification. + /// + /// + /// is resolved from the container. Where what it resolves to is not itself the + /// (Fisher registers a proxy under the marker), the store whose + /// name is the marker's type name — the name all three stores give an + /// ancillary store unless told otherwise — is used instead. + /// + /// No store is registered under . + public CritterStackScenario In() where TStore : class => createView(resolveStore(typeof(TStore))); + + /// + /// bobcat#504. The same Given/Then vocabulary against the event store whose + /// name is (case-insensitive) — "main" for Marten's primary store, the + /// marker type's name for an ancillary one. The view shares this specification; see . + /// + /// No store has that name; the message lists the ones there are. + public CritterStackScenario In(string storeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(storeName); + return createView(Services.EventStore(storeName)); + } + + /// + /// bobcat#504. Build the view and hand out, pinned to + /// . A derived scenario whose own steps should be on the view overrides this and + /// returns its own type, built with the protected view constructor. + /// + protected virtual CritterStackScenario createView(IEventStore store) + { + _state.Touched.Add(store); + return new(this, store); + } + + private IEventStore resolveStore(Type marker) + { + var stores = allStores(); + var resolved = Services.GetService(marker); + if (resolved is IEventStore direct) return stores.FirstOrDefault(x => ReferenceEquals(x, direct)) ?? direct; + + var named = stores.Where(x => identityName(x) is { } name && string.Equals(name, marker.Name, StringComparison.OrdinalIgnoreCase)).ToList(); + if (named.Count == 1) return named[0]; + + throw new InvalidOperationException( + $"No event store is registered under {marker.Name}" + + (resolved is null ? "" : $" ({marker.Name} resolves to a {resolved.GetType().Name}, which is not a JasperFx.Events.IEventStore)") + + $", and no registered store is named '{marker.Name}'. Known: {string.Join(", ", stores.Select(EventStores.Describe))}. " + + "Address it by name with In(\"name\") instead."); + } + + private static string? identityName(IEventStore store) + { + try + { + return store.Identity.Name; + } + catch (Exception) + { + return null; + } + } + + // ---- one tenant (bobcat#536) ------------------------------------------------------------- + + /// + /// bobcat#536. The tenant this view reads and writes as (), or null for the store's + /// default session. + /// + public string? TenantId => _tenant; + + /// + /// bobcat#536. The same Given/Then vocabulary for one tenant of a multi-tenanted store: the events a Given + /// arranges are appended as , and an aggregate, stream, document or read model a Then + /// reads is read as that tenant — through a session opened for it on the database it lives in + /// (, else Marten's Storage.FindOrCreateDatabase, else + /// the store's only database). A projection wait before a read waits on that tenant's database. The view shares + /// this specification exactly as 's does, and the two compose: + /// In<IInvoicingStore>().ForTenant("red"). + /// + /// + /// Only the store's reads and writes are the tenant's. An act through the view is the specification's act and + /// runs as it always did: send it for the tenant the way the application takes one (WolverineFx.Bobcat's + /// DeliveryOptions { TenantId = … }). A store-only act's events are read back from the default session, so + /// on a conjoined store judge them with a tenant's ThenEventsOn/ThenAggregate rather than + /// ThenEvents. + /// + public CritterStackScenario ForTenant(string tenantId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + return createTenantView(tenantId); + } + + /// + /// bobcat#536. Build the view hands out. A derived scenario whose own steps should be on + /// the view overrides this and returns its own type, built with the protected tenant view constructor. + /// + protected virtual CritterStackScenario createTenantView(string tenantId) => new(this, tenantId); + + // A step's text, saying whose data it is when a tenant view takes it + private string asTenant(string text) => _tenant is null ? text : $"{text} for tenant \"{_tenant}\""; + + // Every store the host registers, read once per specification + private IReadOnlyList allStores() => _state.Stores ??= Services.EventStores(); + + /// + /// bobcat#504. The store a step about — an aggregate, projected or document type, or + /// null — whose values are should read or write. The pinned store on a view; the only + /// store on a host with one, exactly as before; otherwise the one store whose registrations claim the model, + /// else its events' or tags' types, else the default store () when it can take them. + /// Throws a naming the candidates and when + /// nothing decides. Cached for the specification. + /// + /// The step, as the failure names it. + /// The aggregate, projected or document type the step is about, when it has one. + /// Events (whole, partial or tagged), or the types of events, whose types and tags help decide. + /// DCB tag types the step names, such as a tag query's. + protected Task storeForAsync(string step, Type? model, IEnumerable? values = null, + IEnumerable? tagTypes = null) + { + if (_pinned is not null) return Task.FromResult(_pinned); + if (allStores().Count <= 1) return Task.FromResult(EventStore); + + return routeAsync(step, model, values, tagTypes); + } + + private async Task routeAsync(string step, Type? model, IEnumerable? values, IEnumerable? tagTypes) + { + + var events = new List(); + var tags = new List(tagTypes ?? []); + foreach (var value in values ?? []) + { + if (value is Type type) + { + events.Add(type); + continue; + } + + events.Add(PartialMatching.ExpectedType(value)); + tags.AddRange(Tagging.ExpectedTagsOf(value).Select(x => x.GetType())); + } + + var store = await router().RouteAsync(step, model, events, tags); + _state.Touched.Add(store); + return store; + } + + private StoreRouter router() => _state.Router ??= new StoreRouter(allStores(), defaultStore()); + + // The constructor's storeName, else the one main store among several, else none + private IEventStore? defaultStore() + => _storeName is not null + ? Services.EventStore(_storeName) + : CritterStackHostExtensions.DefaultEventStore(Services, allStores()); + + /// + /// bobcat#504. The stores an act is observed on: every store the host registers, or — on a host with one or + /// none — when there is one, exactly as before. + /// + protected IReadOnlyList storesToObserve() + { + var root = _root ?? this; + var stores = root.allStores(); + if (stores.Count > 1) return stores; + return root.hasStore() ? [root.EventStore] : []; + } + + /// + /// bobcat#504. A high-water mark on every store returns, taken before an act so + /// can read what each one gained. + /// + protected async Task> highWaterMarksAsync() + { + var marks = new List(); + foreach (var store in storesToObserve()) + marks.Add(new StoreHighWaterMark(store, await EventStores.HighWaterSequenceAsync(store))); + + return marks; + } + + /// Clear the event store and forget the last act, for a suite that shares one host across scenarios. + public virtual async Task ResetAsync(CancellationToken token = default) + { + if (Services.EventStores().Count > 0) await Services.ResetEventStoresAsync(token); + _state.Stream = null; + _state.OwnStreams.Clear(); + _state.ArrangedTags.Clear(); + // bobcat#504: what each store registers is read afresh for the next specification + _state.Router = null; + _state.Touched.Clear(); + LastAct = CritterStackActOutcome.None; + } + + /// + /// The exception a wrong is thrown as when no scenario is recording to gather it. A + /// here; a derived scenario names its own. + /// + protected virtual Exception failureOf(string message) + => _root is not null ? _root.failureOf(message) : new SpecAssertionException(message); + + // ---- arrange --------------------------------------------------------------------------- + + /// + /// for a stream identified by a strong-typed id around a + /// Guid or a string (wolverine#4865), which reads by its own name. + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(object id, params object[] events) where TAggregate : class + => GivenEvents(typeof(TAggregate), id, events); + + /// The stream the scenario acts against already holds exactly these events. + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(Guid id, params object[] events) where TAggregate : class + => GivenEvents(typeof(TAggregate), id, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(string key, params object[] events) where TAggregate : class + => GivenEvents(typeof(TAggregate), key, events); + + /// + /// The stream the scenario acts against already holds exactly these events, and has no aggregate + /// type. Marten, Polecat and Fisher all allow a stream without one; this is the arrange for an event + /// model that names none (GH-4892). Starts the stream when it does not exist, appends when it does. + /// + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(Guid id, params object[] events) => GivenEvents((Type?)null, id, events); + + /// + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(string key, params object[] events) => GivenEvents((Type?)null, key, events); + + /// + /// A null arranges a stream with no aggregate type (GH-4892). + [EventModelRole(SliceRole.Aggregate, nameof(aggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(Type? aggregate, object id, params object[] events) + => arrange(aggregate, id, events, isContext: false); + + // GivenEvents and GivenContext: the same step and the same append. Only an arranged stream the act decides + // against becomes "the stream the scenario is about"; a lookup arranged after it must not take its place + private async Task arrange(Type? aggregate, object id, object[] events, bool isContext) + { + var stream = streamName(aggregate, id); + events = flatten(events); + var (described, inline) = describe(events); + var text = asTenant(events.Length == 0 ? $"{stream} has no events yet" : $"{stream} has already recorded {described}"); + await inStep(ScenarioRecorder.Step("Given", text), async () => + { + // wolverine#4865: a strong-typed id is the Guid or string it wraps, to the store + var identity = StrongTypedIds.StreamIdentity(id); + if (!isContext || _state.Stream is null) _state.Stream = identity; + claimStream(identity); + claimTags(events); + if (aggregate is not null) recordTouched(aggregate); + recordTouchedExpectations(events); + if (!inline) recordValues("event", events); + // Every store's StartStream(Type, ...) takes a null aggregate type as an untyped stream + if (events.Length > 0) + await EventStoreAuthoring.AppendAsync(await storeForAsync(text, aggregate, events), aggregate!, identity, build(events, text), _tenant); + }); + } + + /// + /// bobcat#502. The stream the act looks up already holds exactly these events — the Client an + /// OpenAccount handler loads to check it exists, as opposed to the Account stream the command decides + /// against. At run time it is : the same step, the same + /// append. The difference is the Event Model's: is stamped into the slice's + /// ReadsFrom, never as one of its aggregates. + /// + /// + /// The arranged events are arranged events as for : + /// nothing on a Command slice, consumed on a View one. A context stream never replaces the stream the scenario + /// is about (the one reads) once a GivenEvents has named one. + /// + [EventModelRole(SliceRole.Context, nameof(TContext))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenContext(Guid id, params object[] events) where TContext : class + => GivenContext(typeof(TContext), id, events); + + /// + [EventModelRole(SliceRole.Context, nameof(TContext))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenContext(string key, params object[] events) where TContext : class + => GivenContext(typeof(TContext), key, events); + + /// + /// for a stream identified by a strong-typed id around a + /// Guid or a string (wolverine#4865). + /// + [EventModelRole(SliceRole.Context, nameof(TContext))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenContext(object id, params object[] events) where TContext : class + => GivenContext(typeof(TContext), id, events); + + /// + [EventModelRole(SliceRole.Context, nameof(context))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenContext(Type context, object id, params object[] events) + { + ArgumentNullException.ThrowIfNull(context); + return arrange(context, id, events, isContext: true); + } + + /// + /// The stream the scenario acts against has no events yet. Appends nothing — it says so in the + /// specification, and names the stream the act's events are read from. + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task GivenNoEventsFor(Guid id) where TAggregate : class => GivenEvents(typeof(TAggregate), id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task GivenNoEventsFor(object id) where TAggregate : class => GivenEvents(typeof(TAggregate), id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task GivenNoEventsFor(string key) where TAggregate : class => GivenEvents(typeof(TAggregate), key); + + /// + /// Arrange events on a different stream from the one the act runs against — a second + /// aggregate for a rule that spans two, or history a read model fans in from. + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEventsOn(object id, params object[] events) where TAggregate : class + => GivenEventsOn(typeof(TAggregate), id, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEventsOn(Guid id, params object[] events) where TAggregate : class + => GivenEventsOn(typeof(TAggregate), id, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEventsOn(string key, params object[] events) where TAggregate : class + => GivenEventsOn(typeof(TAggregate), key, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(aggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public async Task GivenEventsOn(Type aggregate, object id, params object[] events) + { + events = flatten(events); + var (described, inline) = describe(events); + var text = asTenant($"{streamName(aggregate, id)} has already recorded {described}"); + await inStep(ScenarioRecorder.Step("Given", text), async () => + { + recordTouched(aggregate); + recordTouchedExpectations(events); + if (!inline) recordValues("event", events); + claimStream(id); + claimTags(events); + if (events.Length > 0) + await EventStoreAuthoring.AppendAsync(await storeForAsync(text, aggregate, events), aggregate, + StrongTypedIds.StreamIdentity(id), build(events, text), _tenant); + }); + } + + /// + /// A read model already exists: is stored directly. The same step as + /// , with inferred from the + /// document, so GivenReadModel(new Flight { … }) needs no type argument (bobcat#619). + /// + [EventModelRole(SliceRole.ArrangedReadModel, nameof(T))] + public Task GivenReadModel(T document) where T : class => GivenReadModel((object)document); + + /// + /// A read model already exists, specified by only some of its members: + /// GivenReadModel(Specify<Flight>().With(x => x.Number, "BA117")), with + /// inferred as the specified type rather than as . + /// + [EventModelRole(SliceRole.ArrangedReadModel, nameof(T))] + public Task GivenReadModel(Specified document) where T : class => GivenReadModel((object)document); + + /// + /// A read model already exists: is stored directly, as when an event + /// model arranges a view rather than the events that build it. + /// + /// + /// may be a partial object, built with its unspecified members + /// filled by . Storing it bypasses the projection, so a spec that + /// is about how the view is built should arrange the events instead. + /// + /// On the Event Model the document is the slice's read model only when the slice is a View; before a + /// command it is a lookup the command decides against, so it is stamped into ReadsFrom + /// (, bobcat#614). + /// + /// + [EventModelRole(SliceRole.ArrangedReadModel, nameof(T))] + public async Task GivenReadModel(object document) where T : class + { + // bobcat#682: "there is a TodoList with Title: ..." -- not "the TodoList read model is TodoList(...)", which + // named the type twice and called a plain document (an [Entity] lookup) a read model + var values = document is IPartialObject partial ? PartialObjects.DescribeValues(partial) : ScenarioValues.DescribeDocument(document); + var inline = values.Length <= ScenarioValues.InlineLimit; + var text = asTenant(inline && values.Length > 0 + ? $"there is a {nameOf(typeof(T))} with {values}" + : $"there is a {nameOf(typeof(T))}"); + await inStep(ScenarioRecorder.Step("Given", text), async () => + { + // bobcat#618: a document too long for the line keeps its values, as a table under the step + if (!inline) recordValues("document", [document]); + + var built = Build(document, text); + if (built is not T) + throw new SpecCriticalException( + $"'{text}': a {nameOf(PartialMatching.ExpectedType(document))} cannot be stored as the {nameOf(typeof(T))} read model."); + + recordTouched(typeof(T)); + await DocumentStores.StoreAllAsync(await storeForAsync(text, typeof(T)), typeof(T), [built], _tenant); + }); + } + + /// + /// How a stream reads in a step: its id is named after the aggregate (GH-4835), so a stream + /// arranged once reads "the Order stream" — and a second one of the same type "Order #Order2". + /// + private static string streamName(Type? aggregate, object id) + { + // A stream with no aggregate type (GH-4892) reads as "the stream", or "stream 2" for a second + var label = aggregate?.Name ?? "stream"; + if (id is string key) return $"{(aggregate is null ? "stream" : nameOf(aggregate))} \"{key}\""; + + ScenarioValues.Learn(id, label); + var name = ScenarioValues.Format(id); + return name == ScenarioValues.Alias(label) ? $"the {label}{(aggregate is null ? "" : " stream")}" : $"{label} {name}"; + } + + /// + /// Values as a step's text shows them: each one in full — Type(Property: value, …) — when + /// they fit on the line, otherwise only their type names, and the caller shows the values as a + /// table under the step instead. + /// + protected static (string Text, bool Inline) describe(IReadOnlyList values) + { + // A partial object reads as only the members it specifies: ShipmentConfirmed(TrackingNumber: 1Z999) + var full = values.Any(x => x is IPartialObject or TaggedValue) + ? string.Join(", ", values.Select(describeOne)) + : ScenarioValues.DescribeAll(values); + return full.Length <= ScenarioValues.InlineLimit ? (full, true) : (names(values), false); + } + + // One value as a step reads it; an event with tags says them after it (wolverine#4865) + private static string describeOne(object value) + { + var described = value switch + { + IPartialObject specified => PartialObjects.Describe(specified), + TaggedValue tagged => ScenarioValues.Describe(tagged.Value), + _ => ScenarioValues.Describe(value) + }; + + var tags = Tagging.ExpectedTagsOf(value); + return tags.Count == 0 ? described : $"{described} tagged {Tagging.Describe(tags)}"; + } + + /// + /// The values a step was given, with each list of partial objects — the rows of a table + /// through PartialObjects.FromTable — spread into its items, so a table and single values + /// mix in one call. + /// + protected static object[] flatten(object[] values) + => values.Any(x => x is IEnumerable) + ? values.SelectMany(x => x is IEnumerable rows ? rows.Cast() : [x]).ToArray() + : values; + + /// A partial object built, its unspecified members filled by ; anything else as it is. + protected internal object Build(object value, string? step = null) => value switch + { + // wolverine#4865: an arranged event keeps the tags it names, so it is appended tagged + IPartialObject specified when specified is ITaggedExpectation { ExpectedTags.Count: > 0 } tagged + => Specifications.Tagged(PartialObjects.Build(specified, UnspecifiedValues, step), tagged.ExpectedTags.ToArray()), + IPartialObject specified => PartialObjects.Build(specified, UnspecifiedValues, step), + _ => value + }; + + private object[] build(object[] values, string step) => values.Select(x => Build(x, step)).ToArray(); + + /// A type alone as an expectation: any one of them, whatever its values. + protected static object anyOf(Type type) => new TablePartialObject(type, []); + + /// Values too long for the step's text, as a table under it: one row each, unjudged. + protected static void recordValues(string noun, IReadOnlyList values) + { + if (!recording || values.Count == 0) return; + + var run = new TableRun([noun, ObjectSetVerification.ValuesColumn]); + for (var i = 0; i < values.Count; i++) + { + run.Cells.Add(new CellResult(noun, ResultStatus.ok, nameOf(PartialMatching.ExpectedType(values[i]))) { RowIndex = i }); + run.Cells.Add(new CellResult(ObjectSetVerification.ValuesColumn, ResultStatus.ok, + PartialMatching.DescribeExpected(values[i])) { RowIndex = i }); + } + + run.Report(); + } + + // ---- run evidence (bobcat#465) --------------------------------------------------------- + + /// Record that the scenario observably touched . No-op outside a recording. + protected static void recordTouched(Type type) => ScenarioRecorder.RecordTouchedType(type); + + /// Record the runtime type of each of as touched. + protected static void recordTouched(IEnumerable values) + { + foreach (var value in values) + { + if (value is not null) recordTouched(value.GetType()); + } + } + + // An arranged value may be a partial object or a tagged one: the type it stands for is what was touched + private static void recordTouchedExpectations(IEnumerable values) + { + foreach (var value in values) recordTouched(PartialMatching.ExpectedType(value)); + } + + // ---- act ------------------------------------------------------------------------------- + + /// + /// Any act an application performs — a domain service, an endpoint called in-process, a background job + /// run by hand — handed a fresh service scope from the host. What it appended is read from the store + /// afterwards; an exception it throws is captured, not rethrown, so a refusal can be asserted with + /// or . + /// + /// How the step reads: "the nightly sweep runs". + /// The act, given the scope's services. + [EventModelAct] + public async Task WhenActing(string description, Func act) + { + ArgumentNullException.ThrowIfNull(act); + // The act's own exception is captured, never thrown; the store reads around it can still throw + await inStep(ScenarioRecorder.Step("When", description), () => recordActAsync(act)); + } + + /// + /// Run and capture what it did. The seam every store-only act goes through: + /// this one takes the store's high-water mark, runs the act in a new service scope, reads back every + /// event appended since, and attributes each one to this specification or not + /// (, bobcat#503) — so + /// and are the same list, nothing is told apart as a + /// cascade, and what concurrent work appended elsewhere is . + /// A derived scenario that can see more of the act, such as one that runs it inside a message bus's tracked + /// session, overrides this. Never throws what the act threw. + /// + /// + /// + /// Read by sequence, attributed by stream. An event counts as the act's when it is on a stream this + /// specification arranged (any Given, GivenContext included), addressed (WhenActingOn), or that one + /// of its acts wrote to — this one's started streams included. An event on any other stream that already + /// existed is foreign: kept aside on , never judged. + /// + /// + /// The residual. A stream another test starts while this act runs looks exactly like one the + /// act started, and still counts. Whole-store assertions on a store-only act therefore still want serial + /// execution on a shared store, or a store or tenant per test; a tracked session (WolverineFx.Bobcat's + /// WolverineScenario) is what makes real parallelism safe. An event appended to another tenant's + /// database is not seen at all. + /// + /// + /// Tags. No store reads an event's tags back, so the outcome says + /// is false, a step that judges tags refuses with that + /// reason, and a tagged (DCB) append is attributed by its stream like any other. Where a store does return + /// tags, an event carrying a tag value this specification arranged events with counts as well. + /// + /// + protected virtual async Task runActAsync(Func act) + { + // bobcat#504: a view's act is its specification's act, run by the scenario the view came from + if (_root is not null) return await _root.runActAsync(act); + + var marks = await highWaterMarksAsync(); + + Exception? error = null; + try + { + await using var scope = Services.CreateAsyncScope(); + await act(scope.ServiceProvider); + } + catch (Exception e) + { + error = e; + } + + // Read even after a failure: an act can commit and then throw, and what it committed is a fact + return await readBackSinceAsync(marks, error); + } + + /// + /// bobcat#504. What a store-only act did, read back from every store were taken on: + /// each store's events since its mark, in sequence order, attributed to this specification or not + /// () store by store, with the streams each one started. Events from + /// several stores are listed store by store, in the order the stores were observed — sequences are a store's + /// own, so there is no order across stores to keep — and + /// says which store each came from. A derived scenario's fallback read uses this too. + /// + /// The high-water marks taken before the act, from . + /// The exception the act raised, if any; carried on the outcome, never thrown. + protected async Task readBackSinceAsync(IReadOnlyList marks, Exception? error) + { + var own = new List(); + var foreign = new List(); + var started = new List(); + + // Only where there is a choice of store to report: a single-store outcome is exactly what it was + var byEvent = marks.Count > 1 ? new Dictionary(ReferenceEqualityComparer.Instance) : null; + + foreach (var mark in marks) + { + var appended = (await EventStores.QueryEventsSinceAsync(mark.Store, mark.Sequence + 1)).OrderBy(x => x.Sequence).ToList(); + var startedHere = await startedStreamsOf(mark.Store, appended); + var (ownHere, foreignHere) = attributeToSpecification(appended, startedHere); + + own.AddRange(ownHere); + foreign.AddRange(foreignHere); + if (ownHere.Count > 0) _state.Touched.Add(mark.Store); + started.AddRange(startedHere); + if (byEvent is not null) + { + foreach (var e in appended) byEvent[e] = mark.Store; + } + } + + return new CritterStackActOutcome(own, error) + { + AllNewEvents = own, + ForeignEvents = foreign, + StartedStreams = started, + // Read back from the store, which returns no tags + EventTagsKnown = false, + StoreByEvent = byEvent ?? CritterStackActOutcome.NoStores + }; + } + + /// + /// bobcat#503. Mark a stream — a Guid, a string key or a strong-typed id around one — as this specification's, + /// so a store-only act attributes the events appended to it. Every Given, WhenActingOn and act already + /// does; a derived scenario's own arrange calls this for a stream it lays down some other way. + /// + protected void claimStream(object id) => _state.OwnStreams.Add(StrongTypedIds.StreamIdentity(id)); + + // The tag values arranged events were appended with, so a store that returns tags can attribute by them + private void claimTags(IEnumerable events) + { + foreach (var tag in events.SelectMany(Tagging.ExpectedTagsOf)) _state.ArrangedTags.Add(tag); + } + + /// + /// bobcat#503. Split what a store-only act read back into this specification's events and foreign ones: an + /// event is the specification's when its stream was arranged, addressed or written by one of its acts, when + /// the act started that stream (), or when it carries a tag value the + /// specification arranged events with. The specification's streams then include every stream this act wrote + /// to, so a second act on a stream the first one started is still attributed. + /// + protected (IReadOnlyList Own, IReadOnlyList Foreign) attributeToSpecification( + IReadOnlyList appended, IReadOnlyList started) + { + foreach (var stream in started) _state.OwnStreams.Add(stream.Key ?? (object)stream.Id); + + var own = new List(); + var foreign = new List(); + foreach (var e in appended) + { + var isOwn = _state.OwnStreams.Contains(streamOf(e)) + || (e.Tags is { Count: > 0 } tags && tags.Any(tag => _state.ArrangedTags.Contains(tag.Value))); + (isOwn ? own : foreign).Add(e); + } + + foreach (var e in own) _state.OwnStreams.Add(streamOf(e)); + return (own, foreign); + } + + private static object streamOf(IEvent e) => e.StreamKey ?? (object)e.StreamId; + + /// + /// bobcat#503. The line a failing whole-store assertion adds when the last act saw events on streams + /// this specification never arranged or started: the likeliest reason the assertion saw something it did + /// not expect, or missed something, is another test writing to the same store. Nothing when there were none, + /// and a passing assertion never shows it. + /// + protected string crossTalkSuffix() + => LastAct.ForeignEvents.Count == 0 + ? "" + : Environment.NewLine + + $"cross-talk suspected: {LastAct.ForeignEvents.Count} event(s) on streams this specification never arranged " + + "or started were appended during the act. Is another test writing to this store concurrently?"; + + /// + /// bobcat#504. The line a failing whole-store assertion adds when the last act was observed on several event + /// stores: which store each of its events came from, store by store. Nothing on a host with one store, and a + /// passing assertion never shows it. + /// + protected string storesSuffix() + { + if (LastAct.StoreByEvent.Count == 0) return ""; + + var all = LastAct.AllNewEvents.Concat(LastAct.ForeignEvents).ToList(); + if (all.Count == 0) return Environment.NewLine + "The act was observed on every event store and appended to none."; + + var byStore = all.GroupBy(x => LastAct.StoreOf(x)) + .Select(x => $"{(x.Key is { } store ? storeLabel(store) : "an unknown store")}: {ScenarioValues.DescribeAll(x.Select(e => e.Data))}"); + return Environment.NewLine + $"Appended by store — {string.Join("; ", byStore)}"; + } + + // How a store reads in a message: its IEventStore.Identity name + private static string storeLabel(IEventStore store) => identityName(store) is { } name ? $"'{name}'" : store.GetType().Name; + + // A stream whose first event is among those appended was started by the act. Its aggregate type comes from + // the store's stream state: an event read back carries none (IEvent.AggregateTypeName is only filled inside + // the async daemon), and without it every started stream would answer to every aggregate type + private static async Task> startedStreamsOf(IEventStore store, IReadOnlyList appended) + { + var started = new List(); + foreach (var e in appended.Where(x => x.Version == 1)) + { + var action = e.StreamKey is { } key + ? new StreamAction(key, StreamActionType.Start) + : new StreamAction(e.StreamId, StreamActionType.Start); + action.AggregateType = await aggregateTypeOf(store, e); + started.Add(action); + } + + return started; + } + + // The aggregate type the store recorded for an event's stream, or null for a stream started without one or + // an alias the store cannot map back to a type (Marten throws for one it has never seen) + private static async Task aggregateTypeOf(IEventStore store, IEvent @event) + { + try + { + var state = @event.StreamKey is { } key + ? await EventStores.FetchStreamStateAsync(store, key) + : await EventStores.FetchStreamStateAsync(store, @event.StreamId); + return state?.AggregateType; + } + catch (Exception) + { + return null; + } + } + + // Run the act through the seam and remember it + private async Task recordActAsync(Func act) + { + var outcome = await runActAsync(act); + LastAct = outcome; + recordTouched(outcome.NewEvents.Select(x => x.Data)); + return outcome; + } + + // ---- a named stream, from a named version (wolverine#4938, bobcat#461) --------------------- + // For an application that does not use Wolverine, or any act whose events nothing else can attribute: + // the spec says WHICH stream and FROM WHICH version, and only what the stream gained past that version + // counts. Store work only. + + /// + /// The version the stream stands at now: its last event's version, or 0 when it has no events. + /// Capture it before an act to name it in . + /// Not a step: it reads, and the assertion that follows is the step. + /// + public Task TheStreamVersion(Guid id) => streamVersion(null, id); + + /// + public Task TheStreamVersion(string key) => streamVersion(null, key); + + /// + /// The stream with this identity gained exactly these events, in this order, + /// past — and nothing else. What it held at or before that version + /// never counts, whoever appended it. Each expected event may be a whole object, an expected value or a + /// partial object (Specify<T>().With(...)), judged on only the members it names. + /// + /// + /// Needs no act of this scenario's: the version is the spec's to name, or to capture with + /// before whatever acts. When the scenario's last act failed, the + /// failure is reported instead of a missing event. A version past the stream's end is a + /// , never a vacuous pass. + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task ThenEventsOn(Guid id, long afterVersion, params object[] events) where T : class + => thenEventsOn(typeof(T), id, afterVersion, flatten(events)); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task ThenEventsOn(string key, long afterVersion, params object[] events) where T : class + => thenEventsOn(typeof(T), key, afterVersion, flatten(events)); + + /// The stream with this identity gained nothing past . + public Task ThenNoEventsOn(Guid id, long afterVersion) where T : class + => thenEventsOn(typeof(T), id, afterVersion, []); + + /// + public Task ThenNoEventsOn(string key, long afterVersion) where T : class + => thenEventsOn(typeof(T), key, afterVersion, []); + + /// + /// One act and its verification, as one paragraph: capture the stream's + /// version, run , and check the stream gained exactly + /// past that version — 's check, with the version + /// captured for you. No events means the act must append nothing to the stream. + /// + /// + /// + /// Three sentences, each with its own verdict, through : "Given the + /// Wallet stream … is at version 2", "When …", "Then WalletCredited is emitted on it". The When reads + /// 's description from the overload that takes one. + /// + /// + /// The act runs through the same seam as , so it is captured into + /// — a failing act is reported by the verification, and a later + /// can assert on it. + /// + /// + [EventModelAct] + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task WhenActingOn(Guid id, Func act, params object[] events) where T : class + => actingOn(typeof(T), id, null, act, flatten(events)); + + /// + [EventModelAct] + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task WhenActingOn(string key, Func act, params object[] events) where T : class + => actingOn(typeof(T), key, null, act, flatten(events)); + + /// + /// The act, as the When sentence says it: "the wallet is credited by hand". + [EventModelAct] + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task WhenActingOn(Guid id, string description, Func act, params object[] events) + where T : class + => actingOn(typeof(T), id, description, act, flatten(events)); + + /// + [EventModelAct] + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task WhenActingOn(string key, string description, Func act, params object[] events) + where T : class + => actingOn(typeof(T), key, description, act, flatten(events)); + + private Task thenEventsOn(Type aggregate, object id, long afterVersion, object[] expected) + { + var text = asTenant($"{describeEmitted(expected)} on the {nameOf(aggregate)} stream {ScenarioValues.Format(id)} after version {afterVersion}"); + return inStep(ScenarioRecorder.Step("Then", text), + () => verifyGained(aggregate, id, afterVersion, expected)); + } + + private async Task actingOn(Type aggregate, object id, string? description, Func act, + object[] expected) + { + ArgumentNullException.ThrowIfNull(act); + recordTouched(aggregate); + ScenarioValues.Learn(id, nameOf(aggregate)); + // bobcat#503: the stream the act is addressed to is the specification's, whoever arranged it + claimStream(id); + + var paragraph = new Paragraph("Given"); + var version = await streamVersion(aggregate, id); + using (paragraph.Sentence($"the {nameOf(aggregate)} stream {ScenarioValues.Format(id)} is at version {version}")) + { + } + + await inStep( + paragraph.Sentence("When", description ?? $"the {nameOf(aggregate)} stream {ScenarioValues.Format(id)} is acted on"), + () => recordActAsync(act)); + + await inStep(paragraph.Sentence("Then", $"{describeEmitted(expected)} on it"), + () => verifyGained(aggregate, id, version, expected)); + } + + private async Task verifyGained(Type aggregate, object id, long afterVersion, object[] expected) + { + var where = $"the {nameOf(aggregate)} stream {ScenarioValues.Format(id)} after version {afterVersion}"; + if (afterVersion < 0) + throw new SpecCriticalException($"A stream version is never negative, so nothing can be checked on {where}."); + + if (LastAct.Error is { } error) + { + fail($"Expected {(expected.Length == 0 ? "no events" : describeTypes(expected.Select(PartialMatching.ExpectedType).ToArray()))} on {where}, " + + $"but the act failed: {error.GetType().Name}: {error.Message}"); + return; + } + + var stream = await fetchAsync(await storeForAsync(where, aggregate, expected), id); + var current = stream.Count == 0 ? 0 : stream.Max(e => e.Version); + + // A version past the end makes every check vacuous: nothing can follow it yet + if (afterVersion > current) + throw new SpecCriticalException( + $"The {nameOf(aggregate)} stream {ScenarioValues.Format(id)} is at version {current}, so nothing can follow " + + $"version {afterVersion} — the version the spec names is ahead of the stream."); + + recordTouched(aggregate); + var gained = stream.Where(e => e.Version > afterVersion).ToList(); + var actual = gained.Select(e => e.Data).ToList(); + + if (expected.Length == 0) + { + fact(actual.Count == 0, $"Expected no events on {where}, but it gained {ScenarioValues.DescribeAll(actual)}"); + return; + } + + var run = ObjectSetVerification.Verify(actual, expected, "event", SetMode.Ordered, tagsOf(gained)); + if (recording) run.Report(); + + fact(run.Succeeded, + (actual.Count == 0 + ? $"Expected events on {where}, but it gained none (it is still at version {current}):" + : $"The events on {where} were not exactly these, in this order:") + + Environment.NewLine + + string.Join(Environment.NewLine, ObjectSetVerification.Problems(run, "event"))); + } + + // A stream with no type to route by is read from the default store (bobcat#504); In() names another + private async Task streamVersion(Type? aggregate, object id) + { + var stream = await fetchAsync(await storeForAsync($"the stream {ScenarioValues.Format(id)}", aggregate), id); + return stream.Count == 0 ? 0 : stream.Max(e => e.Version); + } + + // A step (or a paragraph's sentence) that throws says so on its own line, then the failure carries on to + // the runner. Every step whose body can throw while it is open goes through one of these: a bare + // `using var step` ends as a pass whatever escapes it + private static async Task inStep(IDisposable step, Func body) + { + try + { + await body(); + } + catch (Exception e) + { + (step as IStepHandle)?.Fail(e); + throw; + } + finally + { + step.Dispose(); + } + } + + private static async Task inStep(IDisposable step, Func> body) + { + try + { + return await body(); + } + catch (Exception e) + { + (step as IStepHandle)?.Fail(e); + throw; + } + finally + { + step.Dispose(); + } + } + + private static void inStep(IDisposable step, Action body) + { + try + { + body(); + } + catch (Exception e) + { + (step as IStepHandle)?.Fail(e); + throw; + } + finally + { + step.Dispose(); + } + } + + private static string describeEmitted(object[] expected) + => expected.Length == 0 + ? "no events are emitted" + : $"{describeTypes(expected.Select(PartialMatching.ExpectedType).ToArray())} {(expected.Length == 1 ? "is" : "are")} emitted"; + + private Task> fetchAsync(IEventStore store, object id) => id switch + { + Guid guid => EventStores.FetchStreamAsync(store, guid, _tenant), + string key => EventStores.FetchStreamAsync(store, key, _tenant), + _ => throw new ArgumentOutOfRangeException(nameof(id), $"A stream is identified by a Guid or a string, not a {id.GetType().Name}") + }; + + // ---- assert: events ---------------------------------------------------------------------- + + /// + /// The tags each event was appended with, by the event's data — strong-typed ids, as the store tagged them. + /// + protected static Func?> tagsOf(IReadOnlyList events) + { + var tags = new Dictionary>(ReferenceEqualityComparer.Instance); + foreach (var e in events) + { + tags[e.Data] = e.Tags?.Select(x => x.Value).ToArray() ?? Array.Empty(); + } + + return data => tags.GetValueOrDefault(data); + } + + /// + /// Whether a step about tags can be judged: false, with the reason recorded, when the act's events do not + /// carry their tags () and the step names some. + /// + protected bool tagsCanBeJudged(IEnumerable expected, bool queryByTags = false) + { + if (LastAct.EventTagsKnown) return true; + if (!queryByTags && !expected.Any(x => Tagging.ExpectedTagsOf(x).Count > 0)) return true; + + return fail("The act's events were read back from the event store, which does not return the tags they were " + + "appended with, so a step about tags cannot judge them. Name the stream instead — " + + "ThenEventsOn(id, ...) — or read the boundary's model with TheDcbModel(query)."); + } + + /// The act appended exactly these event types, in this order. + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEvents(params Type[] events) + { + using var step = ScenarioRecorder.Step("Then", $"{names(events)} {(events.Length == 1 ? "is" : "are")} emitted"); + if (!actSucceeded()) return; + + var actual = LastAct.NewEvents.Select(x => x.Data.GetType()).ToArray(); + for (var i = 0; i < Math.Max(actual.Length, events.Length); i++) + { + check("event", i < actual.Length ? actual[i].Name : null, i < events.Length ? events[i].Name : null, i); + } + + fact(actual.SequenceEqual(events), + $"Expected {describeTypes(events)} but the act appended {describeTypes(actual)}{storesSuffix()}{crossTalkSuffix()}"); + } + + /// + /// The act appended exactly these events, in order, compared structurally — property by + /// property, nested properties and collections included, never through Equals (GH-4835). + /// Wrap one in to show but not judge a member nobody controls. + /// + /// + /// An event may also be a partial object — Specify<T>().With(x => x.Prop, value), or the + /// rows of PartialObjects.FromTable — judged and shown on only the members it names (wolverine#4870). + /// Prefer a deterministic clock to Ignoring(...) for a timestamp. + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEvents(params object[] events) + => verifyEvents(flatten(events), SetMode.Ordered, "{0} {1} emitted"); + + /// + /// The act appended exactly these events, in any order — whole objects, s + /// or partial objects, as for . + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsInAnyOrder(params object[] events) + => verifyEvents(flatten(events), SetMode.AnyOrder, "{0} {1} emitted in any order"); + + /// The act appended exactly these event types, in any order. + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsInAnyOrder(params Type[] events) + => verifyEvents(events.Select(anyOf).ToArray(), SetMode.AnyOrder, "{0} {1} emitted in any order"); + + /// + /// The act appended these events among whatever else it appended — whole objects, s + /// or partial objects. Nothing else is judged, or shown. + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEmitted(params object[] events) + => verifyEvents(flatten(events), SetMode.Contains, "{0} {1} emitted"); + + /// The act appended a , whatever its values, among whatever else it appended. + [EventModelRole(SliceRole.Event, nameof(T))] + public void ThenEmitted() => verifyEvents([anyOf(typeof(T))], SetMode.Contains, "{0} {1} emitted"); + + /// The act appended no at all. + public void ThenNotEmitted() => verifyAbsent([typeof(T)]); + + /// + /// The act appended nothing matching any of these — a partial object forbids only the events that + /// agree on the members it names, and a whole object only an equal one. + /// + public void ThenNotEmitted(params object[] events) => verifyAbsent(flatten(events)); + + /// + /// bobcat#683. The act or the work it cascaded into appended these events, among whatever else was + /// appended — read from , where ThenEmitted reads only + /// the act's own . For an event a cascaded handler appended, such as + /// one on a durable local queue that a tracked session waited for. Needs no correlation ids, unlike + /// WolverineFx.Bobcat's ThenEventuallyCaused, but sees only what the act's outcome saw: for a store-only act + /// the two lists are the same. + /// + /// + /// Stamps no Event Model role: a cascaded event usually belongs to another slice, the one whose handler appended it. + /// + public void ThenCaused(params object[] events) => verifyCaused(flatten(events)); + + /// bobcat#683. The act or its cascades appended a , whatever its values — see . + public void ThenCaused() => verifyCaused([anyOf(typeof(T))]); + + private void verifyCaused(object[] expected) + { + using var step = ScenarioRecorder.Step("Then", + $"{describeTypes(expected.Select(PartialMatching.ExpectedType).ToArray())} {(expected.Length == 1 ? "is" : "are")} caused"); + if (!actSucceeded() || !tagsCanBeJudged(expected)) return; + + var run = ObjectSetVerification.Verify(LastAct.AllNewEvents.Select(x => x.Data).ToArray(), expected, "event", + SetMode.Contains, tagsOf(LastAct.AllNewEvents)); + if (recording) run.Report(); + + fact(run.Succeeded, string.Join(Environment.NewLine, ObjectSetVerification.Problems(run, "event")) + storesSuffix() + crossTalkSuffix()); + } + + private void verifyEvents(object[] expected, SetMode mode, string format) + { + var text = string.Format(CultureInfo.InvariantCulture, format, + describeTypes(expected.Select(PartialMatching.ExpectedType).ToArray()), expected.Length == 1 ? "is" : "are"); + using var step = ScenarioRecorder.Step("Then", text); + if (!actSucceeded() || !tagsCanBeJudged(expected)) return; + + var run = ObjectSetVerification.Verify(LastAct.NewEvents.Select(x => x.Data).ToArray(), expected, "event", mode, + tagsOf(LastAct.NewEvents)); + if (recording) run.Report(); + + fact(run.Succeeded, string.Join(Environment.NewLine, ObjectSetVerification.Problems(run, "event")) + storesSuffix() + crossTalkSuffix()); + } + + private void verifyAbsent(object[] forbidden) + { + using var step = ScenarioRecorder.Step("Then", + $"{describeTypes(forbidden.Select(PartialMatching.ExpectedType).ToArray())} {(forbidden.Length == 1 ? "is" : "are")} not emitted"); + if (!actSucceeded() || !tagsCanBeJudged(forbidden)) return; + + var run = ObjectSetVerification.Absent(LastAct.NewEvents.Select(x => x.Data).ToArray(), forbidden, "event", + tagsOf(LastAct.NewEvents)); + if (recording) run.Report(); + + fact(run.Succeeded, string.Join(Environment.NewLine, ObjectSetVerification.Problems(run, "event")) + storesSuffix() + crossTalkSuffix()); + } + + /// + /// GH-4920. The act appended exactly these events, in order, to 's stream + /// with this identity — for a command that decides against several streams, where ThenEvents can + /// only speak for the one the act addresses. Only what the act added counts, never what was arranged. + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsOn(object id, params object[] events) where TAggregate : class + => verifyEventsOn(typeof(TAggregate), id, flatten(events)); + + /// + public void ThenNoEventsOn(object id) where TAggregate : class => verifyEventsOn(typeof(TAggregate), id, []); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsOn(Guid id, params object[] events) where TAggregate : class + => verifyEventsOn(typeof(TAggregate), id, flatten(events)); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsOn(string key, params object[] events) where TAggregate : class + => verifyEventsOn(typeof(TAggregate), key, flatten(events)); + + /// GH-4920. The act appended nothing to 's stream with this identity. + public void ThenNoEventsOn(Guid id) where TAggregate : class => verifyEventsOn(typeof(TAggregate), id, []); + + /// + public void ThenNoEventsOn(string key) where TAggregate : class => verifyEventsOn(typeof(TAggregate), key, []); + + private void verifyEventsOn(Type aggregate, object id, object[] expected) + { + var text = expected.Length == 0 + ? $"no events are emitted on the {nameOf(aggregate)} stream {ScenarioValues.Format(id)}" + : $"{describeTypes(expected.Select(PartialMatching.ExpectedType).ToArray())} {(expected.Length == 1 ? "is" : "are")} emitted on the {nameOf(aggregate)} stream {ScenarioValues.Format(id)}"; + using var step = ScenarioRecorder.Step("Then", text); + if (!actSucceeded() || !tagsCanBeJudged(expected)) return; + + var stream = StrongTypedIds.StreamIdentity(id); + // The step names the stream, so whatever the act appended there is in scope, attributed or not (bobcat#503) + var addressed = addressable(); + var onStream = addressed.Where(x => isOn(x, stream)).Select(x => x.Data).ToArray(); + if (expected.Length == 0) + { + fact(onStream.Length == 0, + $"Expected no events on the {nameOf(aggregate)} stream {id}, but the act appended {ScenarioValues.DescribeAll(onStream)}"); + return; + } + + if (onStream.Length == 0) + { + // Say where the act's events DID go, which is usually the whole answer + // bobcat#504: with several stores, the store too + var elsewhere = LastAct.AllNewEvents + .GroupBy(x => (Store: LastAct.StoreOf(x), Stream: (object?)x.StreamKey ?? x.StreamId)) + .Select(x => $"{(x.Key.Store is { } store ? $"{storeLabel(store)} " : "")}{x.Key.Stream}: {ScenarioValues.DescribeAll(x.Select(e => e.Data))}") + .ToList(); + fact(false, elsewhere.Count == 0 + ? $"Expected events on the {nameOf(aggregate)} stream {id}, but the act appended no events anywhere" + : $"Expected events on the {nameOf(aggregate)} stream {id}, but the act appended none there. It appended to: {string.Join("; ", elsewhere)}"); + return; + } + + verifySet("event", onStream, expected, SetMode.Ordered, tagsOf(addressed)); + } + + // Every event the act appended that a stream- or tag-addressed step may look at: the specification's own and, + // for a store-only act, the foreign ones too, in commit order. Naming the stream is what scopes these steps + // Several stores (bobcat#504): store by store, in sequence within each, since sequences are a store's own + private IReadOnlyList addressable() + { + if (LastAct.ForeignEvents.Count == 0) return LastAct.AllNewEvents; + + var all = LastAct.AllNewEvents.Concat(LastAct.ForeignEvents); + if (LastAct.StoreByEvent.Count == 0) return all.OrderBy(x => x.Sequence).ToList(); + + return all.GroupBy(x => LastAct.StoreOf(x)).SelectMany(x => x.OrderBy(e => e.Sequence)).ToList(); + } + + /// + /// wolverine#4865. The act appended exactly these events, in order, among those + /// selects by their tags — the DCB counterpart to , + /// for a decision whose consistency boundary is a tag query rather than one stream. Only what the act + /// added counts, its cascades' included. + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsOn(EventTagQuery query, params object[] events) + { + var expected = flatten(events); + var scope = EventStores.Describe(query); + var text = expected.Length == 0 + ? $"no events are emitted on {scope}" + : $"{describeTypes(expected.Select(PartialMatching.ExpectedType).ToArray())} {(expected.Length == 1 ? "is" : "are")} emitted on {scope}"; + using var step = ScenarioRecorder.Step("Then", text); + if (!actSucceeded() || !tagsCanBeJudged(expected, queryByTags: true)) return; + + var selected = addressable().Where(x => selects(query, x)).ToList(); + if (expected.Length == 0) + { + fact(selected.Count == 0, + $"Expected no events on {scope}, but the act appended {ScenarioValues.DescribeAll(selected.Select(x => x.Data))}"); + return; + } + + verifySet("event", selected.Select(x => x.Data).ToArray(), expected, SetMode.Ordered, tagsOf(selected)); + } + + /// wolverine#4865. The act appended nothing that selects. + public void ThenNoEventsOn(EventTagQuery query) => ThenEventsOn(query); + + // Whether a tag query selects an event: one of its conditions names a tag the event carries, and + // either no event type or the event's own + private static bool selects(EventTagQuery query, IEvent @event) + => @event.Tags is { Count: > 0 } tags && query.Conditions.Any(condition => + (condition.EventType is null || condition.EventType == @event.Data.GetType()) + && tags.Any(tag => tag.TagType == condition.TagType && Equals(tag.Value, condition.TagValue))); + + /// Whether is on the stream — a Guid or a string key. + protected static bool isOn(IEvent @event, object? id) => id switch + { + Guid guid => @event.StreamId == guid, + string key => @event.StreamKey == key, + _ => false + }; + + /// The act appended nothing — the refusal half of a guard. + public void ThenNoEvents() + { + using var step = ScenarioRecorder.Step("Then", "no events are emitted"); + + // Not actSucceeded(): a refused or throwing act that appended nothing is exactly what this step passes + if (!_state.Acted) + { + fail("Expected the act to append no events, but no act has run."); + return; + } + + fact(LastAct.NewEvents.Count == 0, + $"Expected no events but the act appended {ScenarioValues.DescribeAll(LastAct.NewEvents.Select(x => x.Data))}{storesSuffix()}{crossTalkSuffix()}"); + } + + /// + /// The one event of type the act appended, to assert on further. Not a + /// step: it reads the capture, and the assertion that follows is the step. Throws unless exactly + /// one was appended. + /// + [EventModelRole(SliceRole.Event, nameof(T))] + public T TheEvent() + { + var matching = LastAct.NewEvents.Select(x => x.Data).OfType().ToArray(); + if (matching.Length == 1) return matching[0]; + + throw failureOf($"Expected exactly one {nameOf(typeof(T))} but the act appended {matching.Length}{failureSuffix()}{storesSuffix()}{crossTalkSuffix()}"); + } + + /// Every event the act appended, for a test that wants to look for itself. + public IReadOnlyList TheEvents => LastAct.NewEvents.Select(x => x.Data).ToList(); + + /// + /// The act started a stream with this identity. The identity is + /// usually the decision — a redelivered trigger that reuses an id collides instead of starting a + /// second stream — and ThenEvents cannot say where its events went. + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task ThenStreamIsStarted(object id) where TAggregate : class => ThenStreamIsStarted(typeof(TAggregate), id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task ThenStreamIsStarted(Guid id) where TAggregate : class + => ThenStreamIsStarted(typeof(TAggregate), id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task ThenStreamIsStarted(string key) where TAggregate : class + => ThenStreamIsStarted(typeof(TAggregate), key); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(aggregate))] + public async Task ThenStreamIsStarted(Type aggregate, object id) + { + await inStep(ScenarioRecorder.Step("Then", $"a {nameOf(aggregate)} stream is started with id \"{id}\""), async () => + { + if (!actSucceeded()) return; + + var events = await fetchAsync(await storeForAsync($"a {nameOf(aggregate)} stream is started", aggregate), + StrongTypedIds.StreamIdentity(id)); + fact(events.Count > 0, + $"Expected a {nameOf(aggregate)} stream with id {id}, but no stream exists there. The act appended its events somewhere else, or started no stream at all."); + }); + } + + /// + /// wolverine#4865. One paragraph, two sentences: "a stream is started" and + /// "these events are emitted on it", for a command whose handler starts the stream with an id it assigns — + /// read back from the act (). Each sentence has its own verdict. + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task ThenStreamIsStartedWithEvents(params object[] events) where TAggregate : class + => streamIsStartedWithEvents(typeof(TAggregate), flatten(events)); + + // A spec that knows the id already says ThenStreamIsStarted(id) and ThenEventsOn(id, ...); an id + // overload here would read a lone event as the id + + private async Task streamIsStartedWithEvents(Type aggregate, object[] expected) + { + var paragraph = new Paragraph("Then"); + var stream = await inStep(paragraph.Sentence($"a {nameOf(aggregate)} stream is started"), async () => + { + if (!actSucceeded()) return null; + + var started = startedStreamOrNull(aggregate); + if (started is null) return null; + + ScenarioValues.Learn(started, nameOf(aggregate)); + var events = await fetchAsync(await startedStreamStoreAsync(aggregate, started), started); + fact(events.Count > 0, + $"Expected a {nameOf(aggregate)} stream with id {ScenarioValues.Format(started)}, but no stream exists there."); + return started; + }); + if (stream is null) return; + + var text = $"{describeTypes(expected.Select(PartialMatching.ExpectedType).ToArray())} {(expected.Length == 1 ? "is" : "are")} emitted on it"; + using (paragraph.Sentence(text)) + { + var onStream = LastAct.AllNewEvents.Where(x => isOn(x, stream)).ToList(); + verifySet("event", onStream.Select(x => x.Data).ToArray(), expected, SetMode.Ordered, tagsOf(onStream)); + } + } + + // bobcat#504: the store the act's events on a started stream came from, else the one its type routes to + private Task startedStreamStoreAsync(Type aggregate, object stream) + { + var known = LastAct.AllNewEvents.Where(x => isOn(x, stream)).Select(LastAct.StoreOf).FirstOrDefault(x => x is not null); + return known is not null && _pinned is null ? Task.FromResult(known) : storeForAsync($"a {nameOf(aggregate)} stream is started", aggregate); + } + + // The one stream of this type the act started, or null with the reason recorded as the step's failure + private object? startedStreamOrNull(Type aggregate) + { + var started = LastAct.StartedStreams.Where(x => x.AggregateType is null || x.AggregateType == aggregate).ToList(); + if (started.Count == 1) return started[0].Key ?? (object)started[0].Id; + + fail((started.Count == 0 + ? $"The act started no {nameOf(aggregate)} stream{(LastAct.StartedStreams.Count == 0 ? "; it started no stream at all" : "")}." + : $"The act started {started.Count} {nameOf(aggregate)} streams; name the one you mean with ThenStreamIsStartedWithEvents<{nameOf(aggregate)}>(id, ...)." + + startedByAnotherNote(started.Count)) + crossTalkSuffix()); + return null; + } + + /// The write model, folded from its stream. + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public async Task TheAggregate(Guid id) where T : class + { + recordTouched(typeof(T)); + return await EventStores.AggregateStreamAsync(await storeForAsync($"the {nameOf(typeof(T))} aggregate", typeof(T)), id, _tenant); + } + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public async Task TheAggregate(string key) where T : class + { + recordTouched(typeof(T)); + return await EventStores.AggregateStreamAsync(await storeForAsync($"the {nameOf(typeof(T))} aggregate", typeof(T)), key, _tenant); + } + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task TheAggregate(object id) where T : class => StrongTypedIds.StreamIdentity(id) switch + { + Guid guid => TheAggregate(guid), + var key => TheAggregate((string)key) + }; + + /// + /// GH-4931. The id the last act assigned to the stream it started — for + /// a command that starts a stream, whose id a spec cannot know before the act. + /// + /// + /// A started stream's aggregate type is read back from the store's stream state. A stream started with no + /// aggregate type, or one whose type the store cannot map back to a .NET type, answers to any + /// . + /// + /// The act started no such stream, or more than one. + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Guid TheStartedStream() where TAggregate : class => startedStream(typeof(TAggregate)).Id; + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public string TheStartedStreamKey() where TAggregate : class + => startedStream(typeof(TAggregate)).Key + ?? throw new SpecCriticalException($"The {nameOf(typeof(TAggregate))} stream the act started is identified by a Guid, not a string key. Use TheStartedStream<{nameOf(typeof(TAggregate))}>()."); + + private StreamAction startedStream(Type aggregate) + { + // A stream started with no aggregate type (Storage.StartStream with events only), or one whose type the + // store could not map back, can still be the one + var started = LastAct.StartedStreams.Where(x => x.AggregateType is null || x.AggregateType == aggregate).ToList(); + return started.Count switch + { + 1 => started[0], + 0 => throw new SpecCriticalException((LastAct.StartedStreams.Count == 0 + ? $"The act started no {nameOf(aggregate)} stream; it started no stream at all." + : $"The act started no {nameOf(aggregate)} stream. It started: {string.Join(", ", LastAct.StartedStreams.Select(x => x.AggregateType?.Name ?? "(no aggregate type)"))}.") + + crossTalkSuffix()), + _ => throw new SpecCriticalException($"The act started {started.Count} {nameOf(aggregate)} streams, so there is no one id to name." + + startedByAnotherNote(started.Count) + crossTalkSuffix()) + }; + } + + // bobcat#503's residual: a stream another test starts during the act is indistinguishable from the act's own + private static string startedByAnotherNote(int started) + => started > 1 + ? " If the specification expected one, another test writing to this store may have started the others while the act ran." + : ""; + + // ---- assert: refusals -------------------------------------------------------------------- + + /// + /// The act was refused, and the refusal says : an HTTP endpoint's + /// ProblemDetails, or the exception the act threw — searched with its inner exceptions, and every one + /// of an 's, so a refusal wrapped by the act's plumbing is still found. + /// + /// is empty or whitespace, which every refusal contains. + public void ThenRefusedWith(string reason) => assertRefusal($"refused with \"{reason}\"", reason); + + /// + /// The act was refused with , and the refusal names each of + /// : the values an event model gives a refusal, as in + /// EmailAlreadyInUse { email: joe@example.com }. + /// + /// + /// Checked against the refusal's text, an HTTP endpoint's ProblemDetails or the exception the act + /// threw, so it holds for both and asks nothing of how the refusal is built. + /// + public void ThenRefusedWith(string reason, params object[] mentioning) + { + requireReason(reason); + if (mentioning.Length == 0) + { + ThenRefusedWith(reason); + return; + } + + var named = string.Join(", ", mentioning.Select(ScenarioValues.Format)); + using var step = ScenarioRecorder.Step("Then", $"refused with \"{reason}\", naming {named}"); + + var refusal = refusalText(reason); + if (refusal is null) + { + fail($"Expected the act to be refused with \"{reason}\", but it succeeded."); + return; + } + + var hasReason = reasonMatches(refusal, reason); + cell("reason", hasReason, reason, refusal); + var missing = new List(); + foreach (var value in mentioning) + { + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + var isNamed = refusal.Contains(text, StringComparison.Ordinal); + cell(ScenarioValues.Format(value), isNamed, "named", isNamed ? "named" : "not named"); + if (!isNamed) missing.Add(text); + } + + fact(hasReason && missing.Count == 0, + $"Expected a refusal containing \"{reason}\" and naming {string.Join(", ", mentioning)}, but it was: {refusal}"); + } + + /// + /// The act was refused by throwing a , found anywhere in the + /// exception the act raised (an inner exception, or one of an 's), + /// and it matches when one is given: a partial object naming the + /// members a spec is about, as in Specify<EmailAlreadyInUse>().With(x => x.Email, …). + /// + public TException? ThenRefusedWith(object? expected = null) where TException : Exception + { + if (expected is not null and not IPartialObject) + throw new ArgumentException( + $"Describe the expected {typeof(TException).Name} as a partial object, Specify<{typeof(TException).Name}>().With(...): an exception is matched on the members a spec names, never built whole.", + nameof(expected)); + + var partial = expected as IPartialObject; + var text = partial is null || partial.Values.Count == 0 + ? $"refused with {typeof(TException).Name}" + : $"refused with {PartialObjects.Describe(partial)}"; + using var step = ScenarioRecorder.Step("Then", text); + + if (LastAct.Error is null) + { + fail(LastAct.Refusal is { } refusal + ? $"Expected a {typeof(TException).Name} to be thrown, but the act was refused with a response: {refusal}" + : $"Expected a {typeof(TException).Name} to be thrown, but the act succeeded."); + return null; + } + + var thrown = exceptionsIn(LastAct.Error).OfType().FirstOrDefault(); + // Matched on the search, not the name: an ArgumentNullException is the ArgumentException a spec asked for + cell("exception", thrown is not null, typeof(TException).Name, thrown?.GetType().Name ?? LastAct.Error.GetType().Name); + if (thrown is null) + { + fact(false, + $"Expected a {typeof(TException).Name}, but the act threw {LastAct.Error.GetType().Name}: {LastAct.Error.Message}"); + return null; + } + + if (partial is not null) + { + var run = PropertyCells.Verify(thrown, partial); + if (!run.Succeeded && !recording) + throw failureOf($"{typeof(TException).Name} did not match: {string.Join(", ", PropertyCells.Disagreeing(run))}"); + } + + return thrown; + } + + /// The exception and every exception inside it: inner exceptions, and each of an 's. + protected static IEnumerable exceptionsIn(Exception exception) + { + yield return exception; + + IEnumerable inner = exception switch + { + AggregateException aggregate => aggregate.InnerExceptions, + { InnerException: { } one } => [one], + _ => [] + }; + foreach (var child in inner) + foreach (var nested in exceptionsIn(child)) + yield return nested; + } + + /// + /// The act failed validation with . The same check as + /// , worded for a command. + /// + /// is empty or whitespace, which every refusal contains. + public void ThenValidationFails(string reason) => assertRefusal($"validation fails with \"{reason}\"", reason); + + /// + /// The act was cleanly refused (issue #168): it ran, nothing threw, and it appended nothing — the sad path + /// of a handler that refuses without throwing, such as a messaging railway's Stop. A refusal that + /// answers, such as an HTTP endpoint's 4xx, is refused too. + /// + /// + /// Deliberately reason-less: a clean stop carries no reason anywhere an act surfaces, so a reason clause + /// would assert on nothing. A throwing refusal is or + /// . + /// + public void ThenCommandRefused() + { + using var step = ScenarioRecorder.Step("Then", "the command is refused"); + + if (!_state.Acted) + { + fail("Expected the command to be refused, but no act has run."); + return; + } + + if (LastAct.Error is { } error) + { + fail($"Expected the command to be cleanly refused, but it threw: {error.Message}. " + + "A throwing refusal is what ThenValidationFails or ThenRefusedWith describes."); + return; + } + + if (LastAct.Refusal is not null) return; + + fact(LastAct.NewEvents.Count == 0, + $"Expected the command to be refused, but it emitted {LastAct.NewEvents.Count} event(s): " + + ScenarioValues.DescribeAll(LastAct.NewEvents.Select(e => e.Data)) + storesSuffix() + crossTalkSuffix()); + } + + /// + /// bobcat#683. The act succeeded: it ran, threw nothing and was not refused. The guard against a false green — a + /// specification whose only Then steps look for something absent (ThenNoEvents, + /// ThenNotEmitted) already fails on a failed act, but one with no event step at all says nothing about + /// whether the act worked unless it says so. + /// + public void ThenTheActSucceeded() + { + using var step = ScenarioRecorder.Step("Then", "the act succeeds"); + actSucceeded(); + } + + private void assertRefusal(string text, string reason) + { + requireReason(reason); + using var step = ScenarioRecorder.Step("Then", text); + + var refusal = refusalText(reason); + if (refusal is null) + { + fail($"Expected the act to be refused with \"{reason}\", but it succeeded."); + return; + } + + var matched = reasonMatches(refusal, reason); + cell("reason", matched, reason, refusal); + fact(matched, $"Expected a refusal containing \"{reason}\" but it was: {refusal}"); + } + + /// + /// The text a refusal is judged on: an answer's body, else the first message in the act's exception — or + /// any of its inner exceptions — that names , else all of them. Null when the act + /// was not refused. + /// + private string? refusalText(string reason) + { + if (LastAct.Refusal is { } answer) return answer; + if (LastAct.Error is not { } error) return null; + + var messages = exceptionsIn(error).Select(x => x.Message).Where(x => x.Length > 0).Distinct().ToList(); + return messages.FirstOrDefault(x => reasonMatches(x, reason)) + ?? (messages.Count == 0 ? error.Message : string.Join(" | ", messages)); + } + + private static bool reasonMatches(string refusal, string reason) => refusal.Contains(reason, ReasonComparison); + + // Every refusal's text contains the empty string, so a blank reason would pass on any refusal at all + private static void requireReason(string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("A refusal's reason must say something: an empty or whitespace reason matches every refusal.", + nameof(reason)); + } + + // ---- assert: messages -------------------------------------------------------------------- + + /// + /// The last act caused at least messages of type to be + /// sent, read from . A store-only act tracks no + /// messages, so this needs a scenario whose act does, such as WolverineFx.Bobcat's. + /// + /// is less than 1, which any act satisfies. + [EventModelRole(SliceRole.Message, nameof(T))] + public void ThenMessagesSent(int count = 1) + { + ArgumentOutOfRangeException.ThrowIfLessThan(count, 1); + using var step = ScenarioRecorder.Step("Then", + count == 1 ? $"{nameOf(typeof(T))} is sent" : $"at least {count} {nameOf(typeof(T))} are sent"); + if (!_state.Acted) + { + fail($"Expected {count} message(s) of type {nameOf(typeof(T))} to be sent, but no act has run."); + return; + } + + if (!actSucceeded()) return; + + var sent = LastAct.MessagesSent.OfType().Count(); + cell("sent", sent >= count, count == 1 ? "at least one" : $"at least {count}", sent); + fact(sent >= count, $"Expected at least {count} message(s) of type {nameOf(typeof(T))} to be sent, but {sent} were."); + } + + // ---- assert: read models ----------------------------------------------------------------- + + /// + /// GH-4921. The aggregate of 's stream with this identity, as the store serves it + /// through FetchLatest<T>: a projected snapshot where there is one, else built from the stream, + /// whatever the projection lifecycle. For state built from events. A plain or async-projected document + /// is . Throws when there is no such stream. + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(Guid id) where T : class => thenAggregate(id, null); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(object id) where T : class => thenAggregate(id, null); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(string key) where T : class => thenAggregate(key, null); + + /// + /// GH-4921. The aggregate, as , judged against + /// : a partial object (Specify<T>().With(...), a table, or + /// assertions on its members), or a whole object or structurally. + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(Guid id, object expected) where T : class => thenAggregate(id, expected); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(object id, object expected) where T : class => thenAggregate(id, expected); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(string key, object expected) where T : class => thenAggregate(key, expected); + + private Task thenAggregate(object id, object? expected) where T : class + { + // The step opens before the load, so a missing aggregate fails a rendered step + return inStep(ScenarioRecorder.Step("Then", asTenant(expected is null ? existsText(id) : matchesText(id))), async () => + { + var store = await storeForAsync(existsText(id), typeof(T)); + var aggregate = StrongTypedIds.StreamIdentity(id) switch + { + Guid guid => await EventStores.FetchLatestAsync(store, guid, _tenant), + string key => await EventStores.FetchLatestAsync(store, key, _tenant), + _ => throw new ArgumentOutOfRangeException(nameof(id), "A stream is identified by a Guid or a string") + } ?? throw failureOf( + $"No {nameOf(typeof(T))} with id {id}: FetchLatest found no stream there. The act appended to another stream, or none."); + + recordTouched(typeof(T)); + if (expected is not null) judge(aggregate, expected); + return aggregate; + }); + } + + private static string existsText(object id) => $"there is a {documentNamed(id)}"; + + // bobcat#682: "the Wallet #fromWallet matches", so a from- and a to-wallet checked side by side can be told apart + private static string matchesText(object id) => $"the {documentNamed(id)} matches"; + + // A document by its id: "Product #Product", the alias marked so it cannot read as the type twice (bobcat#640) + private static string documentNamed(object id) => $"{nameOf(typeof(T))} {ScenarioValues.Format(id)}"; + + /// + /// GH-4921. The document of type with this identity, loaded with + /// LoadAsync once the projections have caught up: a plain document, or a projection stored as + /// one. An aggregate built from events is . The same as + /// . + /// + [EventModelRole(SliceRole.Document, nameof(T))] + public Task ThenDocument(object id) where T : class => ThenReadModel(id); + + /// GH-4921. The document, as , judged against . + [EventModelRole(SliceRole.Document, nameof(T))] + public Task ThenDocument(object id, object expected) where T : class => ThenReadModel(id, expected); + + /// + /// The read model with this identity, once the projections have caught up — an async projection + /// read straight after the act reads a stale document. Throws when there is no such document, + /// because there is nothing to hand back. Loaded with LoadAsync, so it is the same as + /// ; for an aggregate built from events, see + /// (GH-4921). + /// + [EventModelRole(SliceRole.ReadModel, nameof(T))] + public Task ThenReadModel(object id) where T : class => thenReadModel(id, null); + + /// + /// The read model with this identity, once the projections have caught up, judged against + /// : a partial object (Specify<T>()) on only the members it + /// names, or a whole object or structurally. Hands the document back. + /// + [EventModelRole(SliceRole.ReadModel, nameof(T))] + public Task ThenReadModel(object id, object expected) where T : class => thenReadModel(id, expected); + + private async Task thenReadModel(object id, object? expected) where T : class + { + await projectionsCaughtUpBefore(typeof(T), id); + + // The step opens before the load, so a missing document fails a rendered step + return await inStep(ScenarioRecorder.Step("Then", asTenant(expected is null ? existsText(id) : matchesText(id))), async () => + { + var document = await EventStoreAuthoring.LoadDocumentAsync(await storeForAsync(existsText(id), typeof(T)), id, _tenant) + ?? throw failureOf( + $"No {nameOf(typeof(T))} document with id {id}. The projections caught up, so either nothing routed to this id or its identity rule differs."); + recordTouched(typeof(T)); + if (expected is not null) judge(document, expected); + return document; + }); + } + + /// + /// No read model with this identity exists once the projections have caught up — a view the + /// events did not create, or one they deleted. + /// + public async Task ThenNoReadModel(object id) where T : class + { + await projectionsCaughtUpBefore(typeof(T), id); + + await inStep(ScenarioRecorder.Step("Then", asTenant($"there is no {documentNamed(id)}")), async () => + { + var document = await EventStoreAuthoring.LoadDocumentAsync(await storeForAsync(existsText(id), typeof(T)), id, _tenant); + fact(document is null, + $"Expected no {nameOf(typeof(T))} document with id {id}, but there is one: {ScenarioValues.Describe(document)}"); + }); + } + + /// + /// The of the stream this scenario is about — the one the act started, or else the + /// one the givens arranged — as the store serves it through FetchLatest<T> once the async + /// daemons have caught up, judged against when one is given. + /// + /// + /// Only for a single-stream projection, registered or self-aggregating: a multi-stream projection has no + /// one stream to be fetched by, so it is refused (wolverine#4865). For a document stored any other way, + /// . With no stream at all — the act started none and the givens arranged + /// none, as in a document app — it is the one in the store (bobcat#616), as + /// reads it. + /// + [EventModelRole(SliceRole.ReadModel, nameof(T))] + public async Task ThenSingleReadModel(object? expected = null) where T : class + { + // A multi-stream projection is the wrong step whether or not there is a stream, so this comes before + // bobcat#616's no-stream reading (bobcat#672) + var kind = await EventStores.ProjectionKindAsync(await storeForAsync(singleText(), typeof(T)), typeof(T)); + if (kind is not null && kind != SubscriptionType.SingleStreamProjection) + throw new SpecCriticalException( + $"ThenSingleReadModel<{nameOf(typeof(T))}>() reads a single-stream projection, but {nameOf(typeof(T))} is a {kind}. " + + $"Name the document instead: ThenReadModel<{nameOf(typeof(T))}>(id, ...)."); + + if (scenarioStreamOrNull() is null) return await theOnly(expected); + + await daemonsCaughtUpBefore(typeof(T)); + + return await inStep(ScenarioRecorder.Step("Then", asTenant(expected is null ? singleText() : matchesText(expected))), async () => + { + var stream = scenarioStream(typeof(T), nameof(ThenSingleReadModel)); + var model = await theLatest(stream) + ?? throw failureOf( + $"No {nameOf(typeof(T))} for the stream {ScenarioValues.Format(stream)}: FetchLatest found nothing there."); + + recordTouched(typeof(T)); + if (expected is not null) judge(model, expected); + return model; + }); + } + + private static string singleText() => $"there is a {nameOf(typeof(T))} for the scenario's stream"; + + /// + /// The document stored under the id of the stream this scenario is about, loaded with + /// LoadAsync once the async daemons have caught up — a projected document on Marten or Polecat — and + /// judged against when one is given. + /// + /// + /// With no stream — the act started none and the givens arranged none — it is the one + /// in the store (bobcat#616): the document a document app's act created under an id + /// the system chose (a Guid.NewGuid() in the endpoint, a cascaded handler, a saga). Exactly one is the + /// subject; none or several is a wrong naming the count. Queried through JasperFx.Events.Documents, so + /// it works on every store. + /// + [EventModelRole(SliceRole.Document, nameof(T))] + public async Task ThenSingleDocument(object? expected = null) where T : class + { + if (scenarioStreamOrNull() is null) return await theOnly(expected); + + await daemonsCaughtUpBefore(typeof(T)); + + return await inStep(ScenarioRecorder.Step("Then", asTenant(expected is null ? singleText() : matchesText(expected))), async () => + { + var stream = scenarioStream(typeof(T), nameof(ThenSingleDocument)); + var document = await EventStoreAuthoring.LoadDocumentAsync(await storeForAsync(singleText(), typeof(T)), stream, _tenant) + ?? throw failureOf( + $"No {nameOf(typeof(T))} document with id {ScenarioValues.Format(stream)}. The async daemons caught up, so nothing stored one there."); + + recordTouched(typeof(T)); + if (expected is not null) judge(document, expected); + return document; + }); + } + + private async Task theLatest(object stream) where T : class + { + var store = await storeForAsync(singleText(), typeof(T)); + return stream switch + { + Guid guid => await EventStores.FetchLatestAsync(store, guid, _tenant), + _ => await EventStores.FetchLatestAsync(store, (string)stream, _tenant) + }; + } + + // The stream a spec without an id means: the one stream the act started, else the one the givens arranged + private object scenarioStream(Type model, string step) + => scenarioStreamOrNull() ?? throw new SpecCriticalException( + $"{step}<{model.Name}>() reads the stream the scenario is about, but the act started none and the givens arranged none. " + + $"Name it: ThenReadModel<{model.Name}>(id, ...)."); + + private object? scenarioStreamOrNull() + => LastAct.StartedStreams is [var started] ? started.Key ?? (object)started.Id : _state.Stream; + + private static string onlyText() => $"there is exactly one {nameOf(typeof(T))}"; + + // bobcat#616: no stream to read by, so the one T in the store — the document a document app's act created + // under an id the system chose. Waits first, as every read of a projected document does. + private async Task theOnly(object? expected) where T : class + { + await projectionsCaughtUpBefore(typeof(T), null); + + return await inStep(ScenarioRecorder.Step("Then", asTenant(expected is null ? onlyText() : matchesText(expected))), async () => + { + var documents = await allDocuments(onlyText()); + if (documents.Count != 1) + { + var found = documents.Count == 0 + ? "there is none" + : $"there are {documents.Count}: {ScenarioValues.DescribeAll(documents.Cast().ToList())}"; + + throw failureOf( + $"Expected exactly one {nameOf(typeof(T))} document, but {found}. The scenario has no stream to read by " + + $"(the act started none and the givens arranged none), so every {nameOf(typeof(T))} in the store counts."); + } + + recordTouched(typeof(T)); + if (expected is not null) judge(documents[0], expected); + return documents[0]; + }); + } + + /// + /// bobcat#683. Every document in the store — for the tenant, on a tenant view — once the + /// async daemons that build it have caught up. A query, not a step: it records nothing and judges nothing. For the + /// documents an act created under ids the system chose, when there may be more than one; with exactly one, + /// says so as a step. Queried through JasperFx.Events.Documents, so it + /// works on every store. + /// + public async Task> TheDocuments() where T : class + { + await projectionsCaughtUpBefore(typeof(T), null); + var documents = await allDocuments(documentsText([])); + recordTouched(typeof(T)); + return documents; + } + + /// + /// bobcat#683. The documents in the store are exactly these, in any order, once the async + /// daemons have caught up — whole objects, s or partial objects, judged as a set: one + /// row per document, MISSING and EXTRA named. No expectations at all means there are none. Every + /// in the store counts, as for without a stream, so on a + /// store other tests share, give the documents a value only this specification uses and name it in a partial. + /// + [EventModelRole(SliceRole.Document, nameof(T))] + public async Task> ThenDocuments(params object[] expected) where T : class + { + await projectionsCaughtUpBefore(typeof(T), null); + var items = flatten(expected); + + return await inStep(ScenarioRecorder.Step("Then", asTenant(documentsText(items))), async () => + { + var documents = await allDocuments(documentsText(items)); + recordTouched(typeof(T)); + verifySet(nameOf(typeof(T)), documents.Cast().ToList(), items, SetMode.AnyOrder); + return documents; + }); + } + + private static string documentsText(object[] expected) + => expected.Length == 0 + ? $"there are no {nameOf(typeof(T))} documents" + : $"the {nameOf(typeof(T))} documents are {ScenarioValues.DescribeAll(expected.Select(unwrap).ToList())}"; + + /// + /// Wait until every asynchronous projection on every database has caught up, so a read model reflects the + /// act — what the WaitForNonStaleResults-style helpers need first. Waits up to + /// . On a view from , on the store it is pinned to. + /// On a host with several stores (bobcat#504), on the default store and every store this specification + /// touched — a step went to it, a view was pinned to it, or an act appended its events there — so a store the + /// specification never involves, such as an embedded CritterWatch console's, cannot hold it up. With no default + /// store, on every store. + /// + public async Task AfterAsyncDaemonsCatchUpAsync() + { + // bobcat#536: a tenant view waits on its tenant's database + if (_tenant is not null) + { + await AfterAsyncDaemonsCatchUpAsync(_tenant); + return; + } + + const string text = "the async daemons have caught up"; + await inStep(ScenarioRecorder.Step("Given", text), async () => + { + foreach (var store in storesToWaitOn()) + await diagnosingTimeout(store, text, () => EventStores.WaitForNonStaleProjectionsAsync(store, ProjectionTimeout)); + }); + } + + /// for one tenant's database, on every store or the view's. + public async Task AfterAsyncDaemonsCatchUpAsync(string tenantId) + { + var text = $"the async daemons have caught up for tenant \"{tenantId}\""; + await inStep(ScenarioRecorder.Step("Given", text), async () => + { + foreach (var store in storesToWaitOn()) + await diagnosingTimeout(store, text, + () => EventStores.WaitForNonStaleProjectionsAsync(store, tenantId, ProjectionTimeout)); + }); + } + + // bobcat#685: a wait that reads nothing afterwards still says which projections were behind + private async Task diagnosingTimeout(IEventStore store, string wait, Func waiting, Type? read = null, + object? identity = null) + { + try + { + await waiting(); + } + catch (TimeoutException e) + { + throw await EventStores.DiagnoseCatchUpTimeoutAsync(store, wait, ProjectionTimeout, e, read, identity); + } + } + + // bobcat#504: the view's store; or, with several, the default store and every store the specification touched + // (every store when there is no default); or (one or none) the store as before + private IReadOnlyList storesToWaitOn() + { + if (_pinned is not null) return [_pinned]; + + var stores = allStores(); + if (stores.Count <= 1) return [EventStore]; + + var fallback = defaultStore(); + if (fallback is null) return stores; + + return stores.Where(store => ReferenceEquals(store, fallback) || _state.Touched.Contains(store)).ToList(); + } + + /// There is no at all once the projections have caught up. + public async Task ThenNoReadModel() where T : class + { + await projectionsCaughtUpBefore(typeof(T), null); + + await inStep(ScenarioRecorder.Step("Then", asTenant($"there is no {nameOf(typeof(T))}")), async () => + { + var documents = await allDocuments($"there is no {nameOf(typeof(T))}"); + fact(documents.Count == 0, + $"Expected no {nameOf(typeof(T))} document, but there {(documents.Count == 1 ? "is one" : $"are {documents.Count}")}: {ScenarioValues.DescribeAll(documents.Cast().ToList())}"); + }); + } + + private async Task> allDocuments(string step) where T : class + { + var store = await storeForAsync(step, typeof(T)); + object session = _tenant is null + ? DocumentStores.SessionFactoryFor(store).QuerySession() + : await DocumentStores.TenantSessionAsync(store, _tenant); + try + { + var queryable = ((JasperFx.Events.Documents.IDocumentReadOperations)session).Query(); + return await JasperFx.Events.Documents.DocumentQueryableExtensions.ToListAsync(queryable); + } + finally + { + if (session is IAsyncDisposable asyncDisposable) await asyncDisposable.DisposeAsync(); + else if (session is IDisposable disposable) disposable.Dispose(); + } + } + + /// + /// The projections that build have caught up: every async daemon is waited + /// on, up to . + /// + [EventModelRole(SliceRole.ReadModel, nameof(readModel))] + public Task ThenProjectionsAreCaughtUp(Type readModel) + { + var text = catchUpText(readModel); + return inStep(ScenarioRecorder.Step("Then", text), () => waitBeforeReading(text, readModel, null)); + } + + private static string catchUpText(Type read) => $"the {read.Name} read model has caught up"; + + // The catch-up step every read of a projected document opens with: its timeout names what was being read. + // Not rendered, and not waited on, when the store has no async projections (bobcat#620) + private async Task projectionsCaughtUpBefore(Type read, object? identity) + { + var text = catchUpText(read); + if (await nothingToCatchUpAsync(text, read)) return; + await inStep(ScenarioRecorder.Step("Then", text), () => waitBeforeReading(text, read, identity)); + } + + // AfterAsyncDaemonsCatchUpAsync's step, before a read of the scenario's own stream; skipped as above + private async Task daemonsCaughtUpBefore(Type read) + { + const string text = "the async daemons have caught up"; + if (await nothingToCatchUpAsync(text, read)) return; + var stream = LastAct.StartedStreams is [var started] ? started.Key ?? (object)started.Id : _state.Stream; + await inStep(ScenarioRecorder.Step("Given", text), () => waitBeforeReading(text, read, stream)); + } + + /// + /// bobcat#620. True only when the store being read positively says it configures no async projection shard: + /// then nothing can be stale, and an implicit catch-up step before every document read is noise. A store that + /// cannot say, or a store that cannot be chosen, is "not known", so the step renders and waits (or fails on its + /// own line) as before. + /// + private async Task nothingToCatchUpAsync(string wait, Type read) + { + try + { + var store = await storeForAsync(wait, read); + return EventStoreShards.TryNames(store) is { Count: 0 }; + } + catch (Exception) + { + return false; + } + } + + /// + /// bobcat#497. Wait for the async projections before a read, and when the wait times out, rethrow it as a + /// that names the wait, the projections that were behind, the + /// timeout and what was being read — still an error, never a specification failure: a daemon that is behind is + /// the environment, not evidence about the document. + /// + private async Task waitBeforeReading(string wait, Type read, object? identity) + { + // bobcat#504: the store being read + var store = await storeForAsync(wait, read); + + // bobcat#536: a tenant view reads its tenant's database, so that is the one to wait on + await diagnosingTimeout(store, wait, () => _tenant is null + ? EventStores.WaitForNonStaleProjectionsAsync(store, ProjectionTimeout) + : EventStores.WaitForNonStaleProjectionsAsync(store, _tenant, ProjectionTimeout), read, identity); + } + + /// + /// wolverine#4865. projected over the events selects by + /// their tags — a Dynamic Consistency Boundary model, or a view built the same way. Null when nothing + /// matches the query. + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public async Task TheDcbModel(EventTagQuery query) where T : class + { + recordTouched(typeof(T)); + var store = await storeForAsync($"{nameOf(typeof(T))} is projected over {EventStores.Describe(query)}", typeof(T), + query.Conditions.Where(x => x.EventType is not null).Select(x => (object)x.EventType!), + query.Conditions.Select(x => x.TagType)); + return await EventStores.AggregateByTagsAsync(store, query, _tenant); + } + + /// + /// wolverine#4865. , projected over the events selects by + /// their tags, is : a whole object, an or a partial + /// one, judged as judges. + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public async Task ThenDcbModel(EventTagQuery query, object expected) where T : class + { + var text = $"the {nameOf(typeof(T))} over {EventStores.Describe(query)} matches"; + var model = await inStep(ScenarioRecorder.Step("Given", $"{nameOf(typeof(T))} is projected over {EventStores.Describe(query)}"), + () => TheDcbModel(query)); + + matches(text, model, expected); + } + + // ---- assert: any object (GH-4836) -------------------------------------------------------- + + /// + /// Verify selected properties of as a table — Bobcat's + /// PropertyCells, one cell per column, nested paths (Address.City) included. A + /// response, a read model, an aggregate, anything. + /// + /// What is being verified. + /// One header row naming the properties, one row of expected values: "| Name | Address.City |\n| Ann | Austin |". + public void Verify(object subject, StepTable expected) + { + using var step = ScenarioRecorder.Step("Then", $"the {nameOf(subject.GetType())} has"); + var run = PropertyCells.Verify(subject, expected); + if (!run.Succeeded && !recording) + { + throw failureOf($"{nameOf(subject.GetType())} did not match: {string.Join(", ", PropertyCells.Disagreeing(run))}"); + } + } + + /// + /// is structurally equal to — or to an + /// with members to ignore. The same rules as . + /// + public void ThenMatches(object? subject, object expected) + => matches(null, subject, expected); + + private void matches(string? text, object? subject, object expected) + { + // bobcat#618: a scalar is one value, not an object with a type to name -- "the value is 2", + // judged in one cell, rather than "the Int32 matches ✓ Int32: Int32 ✓ value: 2" + if (expected is not IPartialObject and not IExpectedValue && ScenarioValues.IsScalar(expected)) + { + using var scalar = ScenarioRecorder.Step("Then", text ?? $"the value is {ScenarioValues.Format(expected)}"); + if (!check("value", subject, expected)) + fail($"Expected {ScenarioValues.Format(expected)} but was {ScenarioValues.Format(subject)}"); + return; + } + + using var step = ScenarioRecorder.Step("Then", text ?? matchesText(expected)); + judge(subject, expected); + } + + // How a step that judges an object against expected reads: a collection by its items when they fit + // on the line ("the collection matches [\"a\", \"b\"]"), anything else by its type + private static string matchesText(object expected) + { + var value = unwrap(expected); + if (expected is not IPartialObject && value is System.Collections.IEnumerable and not string) + { + var items = ScenarioValues.Describe(value); + return items.Length <= ScenarioValues.InlineLimit ? $"the collection matches {items}" : $"the {nameOf(value.GetType())} matches"; + } + + return $"the {nameOf(expected is IPartialObject partial ? partial.Type : value.GetType())} matches"; + } + + // Judge subject against expected on the step in progress: a partial object on the members it names, else structurally + private void judge(object? subject, object expected) + { + if (expected is IPartialObject partial) + { + if (subject is null) + { + fail($"Expected a {nameOf(partial.Type)} but there was nothing to verify"); + return; + } + + var run = PropertyCells.Verify(subject, partial); + if (!run.Succeeded && !recording) + { + throw failureOf($"{nameOf(partial.Type)} did not match: {string.Join(", ", PropertyCells.Disagreeing(run))}"); + } + + return; + } + + compareSequence(nameOf(unwrap(expected).GetType()), subject is null ? Array.Empty() : new[] { subject }, new[] { expected }); + } + + // ---- the clock (ClockGrammars, as recorded steps) ------------------------------------------ + // Each freezes or moves the ambient BobcatClock, which is what relative TODAY/NOW cells resolve against + // and what an application reads when its TimeProvider is BobcatClock.Current. Synchronous on purpose: the + // clock is AsyncLocal, and a value set inside an async method never flows back to its caller. + + /// The ambient clock a step's relative values resolve against: . + public TimeProvider Clock => BobcatClock.Current; + + /// Freeze the clock at midnight UTC on . + public void GivenTheDateIs(DateOnly date) + { + using var step = ScenarioRecorder.Step("Given", $"the date is {date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}"); + BobcatClock.Controllable().SetUtcNow(new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero)); + } + + /// Freeze the clock at . + public void GivenTheTimeIs(DateTimeOffset now) + { + using var step = ScenarioRecorder.Step("Given", $"the current time is {formatInstant(now)}"); + BobcatClock.Controllable().SetUtcNow(now.ToUniversalTime()); + } + + /// Move the clock forward by . + /// is negative: the clock only advances. + public void WhenTheClockAdvancesBy(TimeSpan duration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); + using var step = ScenarioRecorder.Step("When", $"the clock advances by {formatDuration(duration)}"); + BobcatClock.Controllable().Advance(duration); + } + + /// passes: the clock moves forward by it. + /// is negative: time only passes forward. + public void WhenTimePasses(TimeSpan duration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); + using var step = ScenarioRecorder.Step("When", $"{formatDuration(duration)} passes"); + BobcatClock.Controllable().Advance(duration); + } + + /// Move the clock to . + public void WhenTheClockAdvancesTo(DateTimeOffset instant) + { + using var step = ScenarioRecorder.Step("When", $"the clock advances to {formatInstant(instant)}"); + BobcatClock.Controllable().SetUtcNow(instant.ToUniversalTime()); + } + + // "2026-01-02", "2026-01-02 09:15", "2026-01-02 09:15:30.25": UTC, to the tick, in the form ClockGrammars + // reads back. No zone suffix, because its parser assumes UTC and refuses one it does not know + private static string formatInstant(DateTimeOffset instant) + { + var utc = instant.ToUniversalTime(); + var format = utc.TimeOfDay == TimeSpan.Zero ? "yyyy-MM-dd" + : utc.Ticks % TimeSpan.TicksPerMinute == 0 ? "yyyy-MM-dd HH:mm" + : utc.Ticks % TimeSpan.TicksPerSecond == 0 ? "yyyy-MM-dd HH:mm:ss" + : "yyyy-MM-dd HH:mm:ss.FFFFFFF"; + return utc.ToString(format, CultureInfo.InvariantCulture); + } + + // "3 days", "1 hour 30 minutes", "0.5 milliseconds": the words FriendlyTimeSpanParser reads back, to the + // tick. Never negative; the clock steps refuse one before they get here + private static string formatDuration(TimeSpan duration) + { + if (duration == TimeSpan.Zero) return "0 seconds"; + + var parts = new List(); + void add(decimal amount, string unit) + { + if (amount != 0) + parts.Add($"{amount.ToString("0.####", CultureInfo.InvariantCulture)} {unit}{(amount == 1 ? "" : "s")}"); + } + + add(duration.Days, "day"); + add(duration.Hours, "hour"); + add(duration.Minutes, "minute"); + add(duration.Seconds, "second"); + // The milliseconds and whatever is under them: a tick is 0.0001 of a millisecond + add((decimal)(duration.Ticks % TimeSpan.TicksPerSecond) / TimeSpan.TicksPerMillisecond, "millisecond"); + return string.Join(" ", parts); + } + + // ---- shared ------------------------------------------------------------------------------ + + private static bool recording => ScenarioRecorder.Current is not null; + + /// Report a wrong: gathered on the step in progress, or thrown when nothing records. Returns false. + protected bool fail(string message) + { + if (!recording) throw failureOf(message); + + SpecAssert.Fail(message); + return false; + } + + /// Report unless holds. + protected bool fact(bool condition, string because) => condition || fail(because); + + // A judged cell: expected against actual, recorded on the step in progress + private static bool check(string name, object? actual, object? expected, int rowIndex = -1) + { + var matched = ObjectComparison.LeafEquals(actual, expected); + cell(name, matched, expected, actual, rowIndex); + return matched; + } + + private static void cell(string name, bool matched, object? expected, object? actual, int rowIndex = -1, + string? note = null) + { + if (!recording) return; + + ScenarioRecorder.RecordCell(new CellResult(name, matched ? ResultStatus.success : ResultStatus.failed) + { + Expected = ObjectComparison.Format(expected), + Actual = ObjectComparison.Format(actual), + RowIndex = rowIndex, + Note = note + }); + } + + private static void valueCell(string name, object? actual, int rowIndex = -1) + { + if (!recording) return; + ScenarioRecorder.RecordCell(new CellResult(name, ResultStatus.ok, ObjectComparison.Format(actual)) + { + RowIndex = rowIndex + }); + } + + private static bool row(IReadOnlyList leaves, int rowIndex) + { + foreach (var leaf in leaves) + { + if (leaf.Ignored) valueCell(leaf.Path, leaf.Actual, rowIndex); + else cell(leaf.Path, leaf.Matched, leaf.Expected, leaf.Actual, rowIndex, leaf.Note); + } + + return leaves.All(x => x.Matched); + } + + private static string describeLeaves(IEnumerable leaves) + => string.Join("; ", ValueDifferences.Sentences(leaves + .Where(x => !x.Matched) + .Select(x => new ValueDifference(x.Path, x.Expected, x.Actual, x.Note)) + .ToList())); + + private void compareSequence(string noun, IReadOnlyList actual, IReadOnlyList expected) + { + var problems = new List(); + + for (var i = 0; i < Math.Max(actual.Count, expected.Count); i++) + { + var expectedValue = i < expected.Count ? unwrap(expected[i]) : null; + var actualValue = i < actual.Count ? actual[i] : null; + + check(noun, actualValue is null ? null : nameOf(actualValue.GetType()), expectedValue is null ? null : nameOf(expectedValue.GetType()), i); + + if (expectedValue is null) + { + problems.Add($"[{i}] an extra {nameOf(actualValue!.GetType())}"); + continue; + } + + if (actualValue is null) + { + problems.Add($"[{i}] {nameOf(expectedValue.GetType())} is missing"); + continue; + } + + if (actualValue.GetType() != expectedValue.GetType()) + { + problems.Add($"[{i}] expected {nameOf(expectedValue.GetType())} but was {nameOf(actualValue.GetType())}"); + continue; + } + + var leaves = ObjectComparison.Compare(actualValue, expectedValue, + (expected[i] as IExpectedValue)?.Rules ?? ComparisonRules.None); + if (!row(leaves, i)) problems.Add($"[{i}] {nameOf(expectedValue.GetType())}: {describeLeaves(leaves)}"); + } + + fact(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + /// An expected value's value; anything else as it is. + protected static object unwrap(object value) => value is IExpectedValue expected ? expected.Value : value; + + /// + /// against as a set verification (GH-4835): + /// one grid with a row per item — OK, a FAIL naming the values that disagree, MISSING, EXTRA, or + /// ORDER — rather than a positional comparison that reads one missing event as every later one wrong. + /// + protected void verifySet(string noun, IReadOnlyList actual, IReadOnlyList expected, SetMode mode, + Func?>? tags = null) + { + var run = tags is null + ? ObjectSetVerification.Verify(actual, expected, noun, mode) + : ObjectSetVerification.Verify(actual, expected, noun, mode, tags); + + if (recording) run.Report(); + + fact(run.Succeeded, string.Join(Environment.NewLine, ObjectSetVerification.Problems(run, noun))); + } + + /// + /// A failure the step did not ask about is reported as the act failing, rather than as "expected 1 + /// event, got 0" — which would send the reader to the handler's logic when the answer is in the + /// exception the act captured. No act at all is a failure too, so a forgotten When cannot pass a step + /// that looks for nothing. + /// + protected bool actSucceeded() + { + // Without an act every "what the act did" step would pass on the empty outcome + if (!_state.Acted) + { + return fail("Expected an act to judge, but no act has run. A step about what the act did needs a When first."); + } + + if (LastAct.Error is { } error) + { + return fail($"The act failed: {error.GetType().Name}: {error.Message}"); + } + + if (LastAct.Refusal is { } refusal) + { + return fail($"The act was refused: {refusal}"); + } + + return true; + } + + /// What the act's failure or refusal was, as a sentence's tail, or nothing when it succeeded. + protected string failureSuffix() + => LastAct.Error is { } e ? $". The act failed: {e.GetType().Name}: {e.Message}" + : LastAct.Refusal is { } r ? $". The act was refused: {r}" : ""; + + private static string names(IReadOnlyList values) => describeTypes(values.Select(PartialMatching.ExpectedType).ToArray()); + + private static string names(Type[] types) => describeTypes(types); + + /// A type as a step reads it: List<Account>, never List`1 (bobcat#618). + protected static string nameOf(Type type) => ScenarioValues.TypeName(type); + + /// Type names as a step reads them: A, B, or nothing. + protected static string describeTypes(IReadOnlyList types) + => types.Count == 0 ? "nothing" : string.Join(", ", types.Select(nameOf)); +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackSpec.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackSpec.cs new file mode 100644 index 000000000..eef6f0acc --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/CritterStackSpec.cs @@ -0,0 +1,533 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Microsoft.Extensions.Hosting; + +namespace Bobcat.CritterStack; + +/// +/// The base class for specifying an event-sourced application on Marten, Polecat or Fisher in an ordinary +/// xUnit or TUnit test, with the whole store-only Given/When/Then vocabulary of +/// as its own members (bobcat#471). No message bus needed. +/// +/// +/// +/// Put Bobcat's [BobcatFeature] on the class (Bobcat.Xunit records every test in a feature class) +/// and every step renders as a specification; without a recording the same test runs as an ordinary +/// test and a wrong is thrown. +/// +/// +/// [BobcatFeature("Wallets")] +/// public class crediting_a_wallet(AppFixture app) : CritterStackSpec(app.Host) +/// { +/// [Fact] +/// public async Task a_credit_is_recorded() +/// { +/// var id = Guid.NewGuid(); +/// await GivenEvents<Wallet>(id, new WalletOpened(id, "Ann")); +/// await WhenActing("the wallet is credited", s => s.GetRequiredService<Wallets>().Credit(id, 10)); +/// ThenEvents(new WalletCredited(id, 10)); +/// } +/// } +/// +/// +/// Every member is public, not protected, so a test's own helpers in another class can drive it too. +/// WolverineFx.Bobcat's WolverineSpec derives from this class. +/// +/// +public abstract class CritterStackSpec +{ + protected CritterStackSpec(IHost host, string? storeName = null) : this(new CritterStackScenario(host, storeName)) + { + } + + /// A spec over an application's services alone, with no ; throws. + protected CritterStackSpec(IServiceProvider services, string? storeName = null) + : this(new CritterStackScenario(services, storeName)) + { + } + + protected CritterStackSpec(CritterStackScenario scenario) + { + Scenario = scenario ?? throw new ArgumentNullException(nameof(scenario)); + } + + /// The scenario every step delegates to. + public CritterStackScenario Scenario { get; } + + /// + public IHost Host => Scenario.Host; + + /// + public IServiceProvider Services => Scenario.Services; + + /// + public IEventStore EventStore => Scenario.EventStore; + + /// + public IUnspecifiedValues? UnspecifiedValues + { + get => Scenario.UnspecifiedValues; + set => Scenario.UnspecifiedValues = value; + } + + /// + public TimeSpan ProjectionTimeout + { + get => Scenario.ProjectionTimeout; + set => Scenario.ProjectionTimeout = value; + } + + /// + /// A partial object: only the members a specification is about — + /// Specify<ShipmentConfirmed>().With(x => x.TrackingNumber, "1Z999"). Hand one to a + /// Given or a When to build it, or to a Then to judge only those members. + /// + public static Specified Specify() => Specifications.Specify(); + + /// + /// A partial written as a table (bobcat#450): Property | Value rows, or + /// the members as headers over a single row — what the import writes for more than three members. + /// + public static Specified Specify(StepTable table) => Specifications.Specify(table); + + /// + /// A partial to check against, written as assertions on its members (bobcat#450): + /// Specify<Dog>(x => x.Age.ShouldBe(4), x => x.Name.ShouldStartWith("Re")). + /// + public static Specified Specify(params System.Linq.Expressions.Expression>[] checks) + => Specifications.Specify(checks); + + /// + /// A whole event expected with exactly these Dynamic Consistency Boundary tags, in any order + /// (wolverine#4865): ThenEvents(Tagged(new SeatReserved(theScreeningId, "4C"), theScreeningId, theCustomerId)). + /// A partial one says its tags itself: Specify<SeatReserved>().Tagged(theScreeningId). + /// + public static TaggedValue Tagged(object value, params object[] tags) => Specifications.Tagged(value, tags); + + /// + public CritterStackScenario In() where TStore : class => Scenario.In(); + + /// + public CritterStackScenario In(string storeName) => Scenario.In(storeName); + + /// + public CritterStackScenario ForTenant(string tenantId) => Scenario.ForTenant(tenantId); + + /// + public CritterStackActOutcome LastAct => Scenario.LastAct; + + /// + public Task ResetAsync(CancellationToken token = default) => Scenario.ResetAsync(token); + + // ---- arrange --------------------------------------------------------------------------- + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(Guid id, params object[] events) where TAggregate : class + => Scenario.GivenEvents(id, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(string key, params object[] events) where TAggregate : class + => Scenario.GivenEvents(key, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(object id, params object[] events) where TAggregate : class + => Scenario.GivenEvents(id, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(aggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(Type? aggregate, object id, params object[] events) => Scenario.GivenEvents(aggregate, id, events); + + /// + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(Guid id, params object[] events) => Scenario.GivenEvents(id, events); + + /// + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEvents(string key, params object[] events) => Scenario.GivenEvents(key, events); + + /// + [EventModelRole(SliceRole.Context, nameof(TContext))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenContext(Guid id, params object[] events) where TContext : class + => Scenario.GivenContext(id, events); + + /// + [EventModelRole(SliceRole.Context, nameof(TContext))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenContext(string key, params object[] events) where TContext : class + => Scenario.GivenContext(key, events); + + /// + [EventModelRole(SliceRole.Context, nameof(TContext))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenContext(object id, params object[] events) where TContext : class + => Scenario.GivenContext(id, events); + + /// + [EventModelRole(SliceRole.Context, nameof(context))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenContext(Type context, object id, params object[] events) => Scenario.GivenContext(context, id, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task GivenNoEventsFor(Guid id) where TAggregate : class => Scenario.GivenNoEventsFor(id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task GivenNoEventsFor(string key) where TAggregate : class => Scenario.GivenNoEventsFor(key); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task GivenNoEventsFor(object id) where TAggregate : class => Scenario.GivenNoEventsFor(id); + + /// + [EventModelRole(SliceRole.ArrangedReadModel, nameof(T))] + public Task GivenReadModel(object document) where T : class => Scenario.GivenReadModel(document); + + /// + [EventModelRole(SliceRole.ArrangedReadModel, nameof(T))] + public Task GivenReadModel(T document) where T : class => Scenario.GivenReadModel(document); + + /// + [EventModelRole(SliceRole.ArrangedReadModel, nameof(T))] + public Task GivenReadModel(Specified document) where T : class => Scenario.GivenReadModel(document); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEventsOn(Guid id, params object[] events) where TAggregate : class + => Scenario.GivenEventsOn(id, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEventsOn(string key, params object[] events) where TAggregate : class + => Scenario.GivenEventsOn(key, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.ArrangedEvent, nameof(events))] + public Task GivenEventsOn(object id, params object[] events) where TAggregate : class + => Scenario.GivenEventsOn(id, events); + + // ---- act ------------------------------------------------------------------------------- + + /// + [EventModelAct] + public Task WhenActing(string description, Func act) => Scenario.WhenActing(description, act); + + /// + [EventModelAct] + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task WhenActingOn(Guid id, Func act, params object[] events) where T : class + => Scenario.WhenActingOn(id, act, events); + + /// + [EventModelAct] + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task WhenActingOn(string key, Func act, params object[] events) where T : class + => Scenario.WhenActingOn(key, act, events); + + /// + [EventModelAct] + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task WhenActingOn(Guid id, string description, Func act, params object[] events) + where T : class + => Scenario.WhenActingOn(id, description, act, events); + + /// + [EventModelAct] + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task WhenActingOn(string key, string description, Func act, params object[] events) + where T : class + => Scenario.WhenActingOn(key, description, act, events); + + // ---- the clock ------------------------------------------------------------------------- + + /// + public TimeProvider Clock => Scenario.Clock; + + /// + public void GivenTheDateIs(DateOnly date) => Scenario.GivenTheDateIs(date); + + /// + public void GivenTheTimeIs(DateTimeOffset now) => Scenario.GivenTheTimeIs(now); + + /// + public void WhenTheClockAdvancesBy(TimeSpan duration) => Scenario.WhenTheClockAdvancesBy(duration); + + /// + public void WhenTimePasses(TimeSpan duration) => Scenario.WhenTimePasses(duration); + + /// + public void WhenTheClockAdvancesTo(DateTimeOffset instant) => Scenario.WhenTheClockAdvancesTo(instant); + + // ---- assert: events -------------------------------------------------------------------- + + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEvents(params Type[] events) => Scenario.ThenEvents(events); + + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEvents(params object[] events) => Scenario.ThenEvents(events); + + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsInAnyOrder(params object[] events) => Scenario.ThenEventsInAnyOrder(events); + + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsInAnyOrder(params Type[] events) => Scenario.ThenEventsInAnyOrder(events); + + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEmitted(params object[] events) => Scenario.ThenEmitted(events); + + /// + [EventModelRole(SliceRole.Event, nameof(T))] + public void ThenEmitted() => Scenario.ThenEmitted(); + + /// + public void ThenNotEmitted() => Scenario.ThenNotEmitted(); + + /// + public void ThenNotEmitted(params object[] events) => Scenario.ThenNotEmitted(events); + + /// + public void ThenCaused(params object[] events) => Scenario.ThenCaused(events); + + /// + public void ThenCaused() => Scenario.ThenCaused(); + + /// + public void ThenNoEvents() => Scenario.ThenNoEvents(); + + /// + [EventModelRole(SliceRole.Event, nameof(T))] + public T TheEvent() => Scenario.TheEvent(); + + /// + public IReadOnlyList TheEvents => Scenario.TheEvents; + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsOn(Guid id, params object[] events) where TAggregate : class + => Scenario.ThenEventsOn(id, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsOn(string key, params object[] events) where TAggregate : class + => Scenario.ThenEventsOn(key, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsOn(object id, params object[] events) where TAggregate : class + => Scenario.ThenEventsOn(id, events); + + /// + public void ThenNoEventsOn(Guid id) where TAggregate : class => Scenario.ThenNoEventsOn(id); + + /// + public void ThenNoEventsOn(string key) where TAggregate : class => Scenario.ThenNoEventsOn(key); + + /// + public void ThenNoEventsOn(object id) where TAggregate : class => Scenario.ThenNoEventsOn(id); + + /// + [EventModelRole(SliceRole.Event, nameof(events))] + public void ThenEventsOn(EventTagQuery query, params object[] events) => Scenario.ThenEventsOn(query, events); + + /// + public void ThenNoEventsOn(EventTagQuery query) => Scenario.ThenNoEventsOn(query); + + /// + public Task TheStreamVersion(Guid id) => Scenario.TheStreamVersion(id); + + /// + public Task TheStreamVersion(string key) => Scenario.TheStreamVersion(key); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task ThenEventsOn(Guid id, long afterVersion, params object[] events) where T : class + => Scenario.ThenEventsOn(id, afterVersion, events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task ThenEventsOn(string key, long afterVersion, params object[] events) where T : class + => Scenario.ThenEventsOn(key, afterVersion, events); + + /// + public Task ThenNoEventsOn(Guid id, long afterVersion) where T : class => Scenario.ThenNoEventsOn(id, afterVersion); + + /// + public Task ThenNoEventsOn(string key, long afterVersion) where T : class => Scenario.ThenNoEventsOn(key, afterVersion); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task ThenStreamIsStarted(Guid id) where TAggregate : class => Scenario.ThenStreamIsStarted(id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task ThenStreamIsStarted(string key) where TAggregate : class => Scenario.ThenStreamIsStarted(key); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Task ThenStreamIsStarted(object id) where TAggregate : class => Scenario.ThenStreamIsStarted(id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + [EventModelRole(SliceRole.Event, nameof(events))] + public Task ThenStreamIsStartedWithEvents(params object[] events) where TAggregate : class + => Scenario.ThenStreamIsStartedWithEvents(events); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public Guid TheStartedStream() where TAggregate : class => Scenario.TheStartedStream(); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(TAggregate))] + public string TheStartedStreamKey() where TAggregate : class => Scenario.TheStartedStreamKey(); + + // ---- assert: refusals ------------------------------------------------------------------ + + /// + public void ThenRefusedWith(string reason) => Scenario.ThenRefusedWith(reason); + + /// + public void ThenRefusedWith(string reason, params object[] mentioning) => Scenario.ThenRefusedWith(reason, mentioning); + + /// + public TException? ThenRefusedWith(object? expected = null) where TException : Exception + => Scenario.ThenRefusedWith(expected); + + /// + public void ThenValidationFails(string reason) => Scenario.ThenValidationFails(reason); + + /// + public void ThenCommandRefused() => Scenario.ThenCommandRefused(); + + /// + public void ThenTheActSucceeded() => Scenario.ThenTheActSucceeded(); + + /// + [EventModelRole(SliceRole.Message, nameof(T))] + public void ThenMessagesSent(int count = 1) => Scenario.ThenMessagesSent(count); + + // ---- assert: aggregates, documents and read models ------------------------------------ + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task TheAggregate(Guid id) where T : class => Scenario.TheAggregate(id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task TheAggregate(string key) where T : class => Scenario.TheAggregate(key); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task TheAggregate(object id) where T : class => Scenario.TheAggregate(id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(Guid id) where T : class => Scenario.ThenAggregate(id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(string key) where T : class => Scenario.ThenAggregate(key); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(object id) where T : class => Scenario.ThenAggregate(id); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(Guid id, object expected) where T : class => Scenario.ThenAggregate(id, expected); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(string key, object expected) where T : class => Scenario.ThenAggregate(key, expected); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenAggregate(object id, object expected) where T : class => Scenario.ThenAggregate(id, expected); + + /// + [EventModelRole(SliceRole.Document, nameof(T))] + public Task ThenDocument(object id) where T : class => Scenario.ThenDocument(id); + + /// + [EventModelRole(SliceRole.Document, nameof(T))] + public Task ThenDocument(object id, object expected) where T : class => Scenario.ThenDocument(id, expected); + + /// + [EventModelRole(SliceRole.ReadModel, nameof(T))] + public Task ThenReadModel(object id) where T : class => Scenario.ThenReadModel(id); + + /// + [EventModelRole(SliceRole.ReadModel, nameof(T))] + public Task ThenReadModel(object id, object expected) where T : class => Scenario.ThenReadModel(id, expected); + + /// + public Task ThenNoReadModel(object id) where T : class => Scenario.ThenNoReadModel(id); + + /// + public Task ThenNoReadModel() where T : class => Scenario.ThenNoReadModel(); + + /// + [EventModelRole(SliceRole.ReadModel, nameof(T))] + public Task ThenSingleReadModel(object? expected = null) where T : class => Scenario.ThenSingleReadModel(expected); + + /// + [EventModelRole(SliceRole.Document, nameof(T))] + public Task ThenSingleDocument(object? expected = null) where T : class => Scenario.ThenSingleDocument(expected); + + /// + public Task> TheDocuments() where T : class => Scenario.TheDocuments(); + + /// + [EventModelRole(SliceRole.Document, nameof(T))] + public Task> ThenDocuments(params object[] expected) where T : class => Scenario.ThenDocuments(expected); + + /// + [EventModelRole(SliceRole.ReadModel, nameof(readModel))] + public Task ThenProjectionsAreCaughtUp(Type readModel) => Scenario.ThenProjectionsAreCaughtUp(readModel); + + /// + public Task AfterAsyncDaemonsCatchUpAsync() => Scenario.AfterAsyncDaemonsCatchUpAsync(); + + /// + public Task AfterAsyncDaemonsCatchUpAsync(string tenantId) => Scenario.AfterAsyncDaemonsCatchUpAsync(tenantId); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task TheDcbModel(EventTagQuery query) where T : class => Scenario.TheDcbModel(query); + + /// + [EventModelRole(SliceRole.Aggregate, nameof(T))] + public Task ThenDcbModel(EventTagQuery query, object expected) where T : class => Scenario.ThenDcbModel(query, expected); + + // ---- assert: any object ---------------------------------------------------------------- + + /// + public void Verify(object subject, StepTable expected) => Scenario.Verify(subject, expected); + + /// + public void ThenMatches(object? subject, object expected) => Scenario.ThenMatches(subject, expected); +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/DocumentStores.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/DocumentStores.cs new file mode 100644 index 000000000..df5ede1f8 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/DocumentStores.cs @@ -0,0 +1,186 @@ +using System.Reflection; +using JasperFx.Events; +using JasperFx.Events.Documents; + +namespace Bobcat.CritterStack; + +/// +/// The document half of the store, reached the same store-agnostic way +/// reaches the event half: through the JasperFx.Events.Documents abstractions, with no +/// reference to Marten, Polecat or Fisher (issue #270). +/// +/// +/// +/// Why this exists. The shipped Critter Stack vocabulary assumed event sourcing, so an +/// ordinary document-backed Wolverine application — Storage.Insert, [Entity], a +/// revisioned document — could arrange nothing, assert nothing about its own state, and had to +/// write a private grammar before it could write its first scenario. Four of the ten shipped steps +/// applied to it, and the four were the messaging and HTTP halves. +/// +/// +/// How the store is found, and why nothing new has to be registered. On Marten and Fisher +/// alike the concrete store object implements and +/// — the same instance, one registration. So the document +/// steps resolve exactly what the event steps already resolve, through the host's +/// IHostResource, and cast. A store that is one without the other is named in the +/// exception rather than silently producing an empty result. +/// +/// +/// Why the load is reflected, and why it is public. LoadAsync<T> is +/// generic-only on every store, while a {document} capture yields nothing but a +/// — so something has to close the generic at run time. Every grammar taking a +/// type-name capture meets the same wall, which is why it is solved once here and exposed, rather +/// than re-derived in each consumer's copy. Same reasoning as making +/// public (issue #272). +/// +/// +public static class DocumentStores +{ + /// + /// The document-session factory behind an event store handle. Both are the same object on + /// every Critter Stack store, so this is a checked cast with a message rather than a lookup. + /// + public static IDocumentSessionFactory SessionFactoryFor(IEventStore store) + => store as IDocumentSessionFactory + ?? throw new InvalidOperationException( + $"The store {EventStores.Describe(store)} is a JasperFx.Events.IEventStore but not a " + + "JasperFx.Events.Documents.IDocumentSessionFactory, so Bobcat cannot reach its documents. " + + "Marten, Polecat and Fisher all implement both on the one store object."); + + /// + /// Load one document of by id, or null when there is none. + /// The reflected close over LoadAsync<T> that a {document}-captured step + /// cannot avoid. + /// + public static async Task LoadAsync(IEventStore store, Type documentType, object id, + CancellationToken token = default) + { + var session = SessionFactoryFor(store).QuerySession(); + try + { + var load = loadMethod(documentType); + var task = (Task)load.Invoke(session, [id, token])!; + await task.ConfigureAwait(false); + return task.GetType().GetProperty("Result")!.GetValue(task); + } + finally + { + await disposeAsync(session).ConfigureAwait(false); + } + } + + /// + /// Store every document — all of — and commit, which is the + /// arrange half of a document-backed spec: the rows the behaviour under test runs against. + /// + public static Task StoreAllAsync(IEventStore store, Type documentType, + IReadOnlyList documents, CancellationToken token = default) + => StoreAllAsync(store, documentType, documents, null, token); + + /// + /// bobcat#536. for one + /// tenant: stored and committed through a session opened for on the database that + /// tenant lives in. A null tenant is the store's lightweight session. + /// + public static async Task StoreAllAsync(IEventStore store, Type documentType, + IReadOnlyList documents, string? tenantId, CancellationToken token = default) + { + if (documents.Count == 0) return; + + var session = tenantId is null ? SessionFactoryFor(store).LightweightSession() : await TenantSessionAsync(store, tenantId, token); + try + { + if (session is not IDocumentWriteOperations writes) + throw new InvalidOperationException( + $"The lightweight session {session.GetType().FullName} opened by " + + $"{EventStores.Describe(store)} is not a JasperFx.Events.Documents.IDocumentWriteOperations, " + + "so Bobcat cannot store documents through it."); + + // Store(params T[]) — closed over the document type so the store writes to the + // right table. A typed array, not object[]: the params element type is T. + var typed = Array.CreateInstance(documentType, documents.Count); + for (var i = 0; i < documents.Count; i++) typed.SetValue(documents[i], i); + + storeMethod(documentType).Invoke(writes, [typed]); + + await session.SaveChangesAsync(token).ConfigureAwait(false); + } + finally + { + await disposeAsync(session).ConfigureAwait(false); + } + } + + /// + /// bobcat#536. A document session for one tenant, opened on the database that tenant lives in. Not + /// IDocumentSessionFactory.LightweightSession(tenantId): Marten does not implement that overload, so it + /// throws there. The event store's own tenant session () is the + /// same session on every store, and is one. + /// + public static async Task TenantSessionAsync(IEventStore store, string tenantId, + CancellationToken token = default) + { + var session = await EventStores.OpenTenantSessionAsync(store, tenantId, token).ConfigureAwait(false); + if (session is IDocumentSessionOperations documents) return documents; + + await session.DisposeAsync().ConfigureAwait(false); + throw new InvalidOperationException( + $"The tenant session {session.GetType().FullName} opened by {EventStores.Describe(store)} is not a " + + "JasperFx.Events.Documents.IDocumentSessionOperations, so Bobcat cannot reach that tenant's documents."); + } + + /// + /// The id a step wrote as text, as the type the document is actually keyed by. Shared with the + /// read-model steps' rule, and for the same reason: LoadAsync<T> is chosen by the + /// id's CLR type, so handing a Guid-keyed document a string finds nothing at all — a false + /// "no such document" rather than an error. + /// + public static object IdentityOf(Type documentType, string id) + { + var identity = documentType.GetProperty("Id")?.PropertyType + ?? documentType.GetField("Id")?.FieldType; + + if (identity is null) return Guid.TryParse(id, out var guid) ? guid : id; + + try + { + return Runtime.CellValues.Read(id, identity) + ?? throw new Engine.SpecCriticalException( + $"'{id}' is not a usable {documentType.Name} identity — it converted to null."); + } + catch (Exception e) when (e is FormatException or OverflowException or ArgumentException) + { + throw new Engine.SpecCriticalException( + $"'{id}' is not a valid {identity.Name}, which is what {documentType.Name}.Id is keyed by.", e); + } + } + + private static MethodInfo loadMethod(Type documentType) + => typeof(IDocumentReadOperations) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Single(m => m.Name == nameof(IDocumentReadOperations.LoadAsync) + && m.IsGenericMethodDefinition + && m.GetParameters() is [{ ParameterType.IsGenericParameter: false } first, _] + && first.ParameterType == typeof(object)) + .MakeGenericMethod(documentType); + + private static MethodInfo storeMethod(Type documentType) + => typeof(IDocumentWriteOperations) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Single(m => m.Name == nameof(IDocumentWriteOperations.Store) && m.IsGenericMethodDefinition) + .MakeGenericMethod(documentType); + + private static ValueTask disposeAsync(object session) + => session switch + { + IAsyncDisposable async => async.DisposeAsync(), + IDisposable sync => Dispose(sync), + _ => ValueTask.CompletedTask, + }; + + private static ValueTask Dispose(IDisposable disposable) + { + disposable.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/EventStoreAuthoring.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/EventStoreAuthoring.cs new file mode 100644 index 000000000..49ddfb9c2 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/EventStoreAuthoring.cs @@ -0,0 +1,172 @@ +using System.Reflection; +using JasperFx.Events; + +namespace Bobcat.CritterStack; + +/// +/// The two write-side / document-side operations a spec needs that JasperFx.Events 2.37.0 +/// has no abstraction for — appending arrange-events to a stream, and loading a +/// projected read-model document by id. Both go through the convention Marten, Polecat and Fisher +/// share, reached without a reference to any of them, exactly as ' aggregate +/// and reset helpers do. When JasperFx.Events grows the abstraction, this file is what gets deleted. +/// +/// +/// +/// A session is opened through the store's IEventStore<TOperations, TQuerySession> closure; +/// its Events member is the write surface, and +/// SaveChangesAsync(CancellationToken) / LoadAsync<T>(id, ct) are found by name — the +/// shape all three stores share. A store matching none gets an exception naming what was looked for +/// rather than a silent pass. +/// +/// +/// Public because a spec outside a Bobcat run needs it (issue #324's projected lane). Both +/// methods take an and nothing else — no IStepContext, no fixture, +/// no run — so they are exactly what a plain xUnit or TUnit test can use to arrange a stream and +/// assert a read model. They were internal while the (since removed) Gherkin fixture was the only +/// caller. +/// +/// +/// It sits in the same tier as , and , both of +/// which were already public. +/// +/// +public static class EventStoreAuthoring +{ + /// + /// Append to the stream, starting it (as an + /// stream) when it does not exist yet and appending when it does. + /// This is the arrange half of an event-sourcing spec — the prior events a command runs against. + /// + public static Task AppendAsync(IEventStore store, Type aggregateType, object streamIdentity, + IReadOnlyList events, CancellationToken token = default) + => AppendAsync(store, aggregateType, streamIdentity, events, null, token); + + /// + /// bobcat#536. for + /// one tenant: the stream is read and written through a session opened for on the + /// database that tenant lives in (). A null tenant is the store's + /// default session. Internal, so the public surface keeps the one AppendAsync a plain test looks up by + /// name; outside Bobcat, open the tenant's session with . + /// + internal static async Task AppendAsync(IEventStore store, Type aggregateType, object streamIdentity, + IReadOnlyList events, string? tenantId, CancellationToken token = default) + { + if (events.Count == 0) return; + + // Both stream identity kinds, dispatched here so the fixture stays polymorphic + // (bobcat#177): a Guid-identified store gets the Guid overloads, a string-keyed one + // (Stoat, CritterWatch) the streamKey overloads. Anything else is a wiring mistake. + var existing = streamIdentity switch + { + Guid guid => await EventStores.FetchStreamAsync(store, guid, tenantId, token).ConfigureAwait(false), + string key => await EventStores.FetchStreamAsync(store, key, tenantId, token).ConfigureAwait(false), + _ => throw new ArgumentException( + $"A stream identity must be a Guid or a string, not {streamIdentity.GetType().Name}.", nameof(streamIdentity)), + }; + + var session = await EventStoreSessions.OpenAsync(store, tenantId, token).ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + var operations = eventOperationsOf(session); + + // JasperFx/wolverine#4865: an arranged event with tags -- Tagged(new ScreeningScheduled(...), + // theScreeningId) -- is appended already built and tagged, as a DCB decision would have left it + if (events.Any(x => x is TaggedValue)) + { + var builder = EventStoreSessions.EventStoreOperationsOf(session); + events = events + .Select(x => x is TaggedValue tagged ? builder.BuildEvent(tagged.Value).WithTag(tagged.ExpectedTags.ToArray()) : x) + .ToList(); + } + + if (existing.Count == 0) + { + if (streamIdentity is Guid guid) operations.StartStream(aggregateType, guid, events.ToArray()); + else operations.StartStream(aggregateType, (string)streamIdentity, events.ToArray()); + } + else + { + if (streamIdentity is Guid guid) operations.Append(guid, events.ToArray()); + else operations.Append((string)streamIdentity, events.ToArray()); + } + + await saveChangesAsync(session, token).ConfigureAwait(false); + } + } + + /// + /// Load a projected read-model document of type by id, through the + /// session's LoadAsync<T>(id, ct). may be a or a + /// string key. Returns null when no such document exists. + /// + public static Task LoadDocumentAsync(IEventStore store, object id, CancellationToken token = default) + where T : class + => LoadDocumentAsync(store, id, null, token); + + /// + /// bobcat#536. as + /// sees it. A null tenant is the store's default session. Internal for the reason the + /// tenant AppendAsync is. + /// + internal static async Task LoadDocumentAsync(IEventStore store, object id, string? tenantId, + CancellationToken token = default) + where T : class + { + var session = await EventStoreSessions.OpenAsync(store, tenantId, token).ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + var load = loadMethod(session.GetType(), typeof(T), id.GetType()); + var task = (Task)load.Invoke(session, [id, token])!; + await task.ConfigureAwait(false); + + // Task.Result via the compile-time-unknown closed generic. + var resultProperty = task.GetType().GetProperty("Result")!; + return (T?)resultProperty.GetValue(task); + } + } + + private static IEventOperations eventOperationsOf(object session) + { + if (session is IEventOperations direct) return direct; + + var property = session.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Concat(session.GetType().GetInterfaces().SelectMany(i => i.GetProperties(BindingFlags.Public | BindingFlags.Instance))) + .FirstOrDefault(p => p.Name == "Events" && typeof(IEventOperations).IsAssignableFrom(p.PropertyType)); + + if (property?.GetValue(session) is IEventOperations events) return events; + + throw new InvalidOperationException( + $"Cannot append events through session type {session.GetType().FullName}: it is neither a " + + "JasperFx.Events.IEventOperations nor exposes a public 'Events' member that is one. Marten, Polecat and " + + "Fisher sessions all do."); + } + + private static async Task saveChangesAsync(object session, CancellationToken token) + { + var method = session.GetType().GetMethod("SaveChangesAsync", + BindingFlags.Public | BindingFlags.Instance, [typeof(CancellationToken)]) + ?? throw new InvalidOperationException( + $"Session type {session.GetType().FullName} has no public SaveChangesAsync(CancellationToken); " + + "Bobcat cannot commit the arrange-events it appended. Marten, Polecat and Fisher sessions all have one."); + + await ((Task)method.Invoke(session, [token])!).ConfigureAwait(false); + } + + private static MethodInfo loadMethod(Type sessionType, Type documentType, Type idType) + { + var candidate = sessionType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(m => m.Name == "LoadAsync" + && m.IsGenericMethodDefinition + && m.GetGenericArguments().Length == 1 + && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType == idType + && m.GetParameters()[1].ParameterType == typeof(CancellationToken)) + ?? throw new InvalidOperationException( + $"Session type {sessionType.FullName} has no public LoadAsync({idType.Name}, CancellationToken) to " + + "load a read-model document by id. Marten and Polecat sessions have one; is the id type right " + + "(Guid vs string)?"); + + return candidate.MakeGenericMethod(documentType); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/EventStores.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/EventStores.cs new file mode 100644 index 000000000..48bf4848d --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/EventStores.cs @@ -0,0 +1,971 @@ +using System.Diagnostics; +using System.Reflection; +using JasperFx.Events; +using JasperFx.Events.Daemon; +using JasperFx.Events.Descriptors; +using JasperFx.Events.Projections; +using JasperFx.Events.Tags; + +namespace Bobcat.CritterStack; + +/// +/// The store-agnostic binding: every event-store operation Bobcat's Critter Stack helpers need, +/// expressed against the JasperFx.Events abstractions an exposes +/// rather than against Marten, Polecat or Fisher. Marten, Polecat and Fisher all register their +/// store as in the host container, which is how +/// finds one without a compile-time reference to any of +/// them. +/// +/// +/// +/// Two operations have no JasperFx.Events abstraction in the version Bobcat pins (2.53.0), and are +/// reached through the convention the three stores share rather than through an interface: +/// +/// +/// Rebuilding an aggregate — can fetch a stream but not +/// aggregate it. Marten and Polecat hand back their session's from +/// , which can; Fisher does not, so the fallback opens +/// a session through the store's IEventStore<TOperations, TQuerySession> closure and finds +/// the session's Events member. One reflective lookup, cached per store type. +/// Wiping data between scenarios — JasperFx's IStatefulResource.ClearState is the +/// abstraction, but Marten 9.22's database does not implement IDatabaseWithRewindableState, so +/// jasperfx resources clear is a no-op for it. The stores do agree on +/// store.Advanced.ResetAllData(ct) (Fisher spells it ResetAllDataAsync) and on +/// store.Advanced.Clean.DeleteAllEventDataAsync() / DeleteAllDocumentsAsync(), so +/// follows that convention and says exactly what it looked for when a +/// store matches neither. +/// +/// +/// Both are the bounded, documented softening of "bind to the abstractions" — the same bargain as +/// GrammarBehaviors.Resolve in core. When JasperFx.Events grows the abstraction, the +/// convention path is the one line to delete. Re-checked against 2.53.0 (issue #103): neither has +/// landed, and both branches are now proved on the real stores — Marten takes the +/// / ResetAllData branch, Fisher the session-closure / +/// ResetAllDataAsync one. +/// +/// +/// Why the projection waits do not delegate to ProjectionScenario<,> +/// (JasperFx.Events.TestSupport, in the pinned version): it is a scripted harness that wipes +/// the store, builds its own daemon and appends through its own session, reached only +/// through each store's Advanced member and closed over that store's session pair — there is +/// no non-generic interface and no accessor. A spec appends through the +/// application under test and waits on the host's daemon, so the right tool is the wait the +/// scenario itself performs after each batch, which is what +/// calls directly. +/// +/// +public static class EventStores +{ + public static readonly TimeSpan DefaultProjectionTimeout = TimeSpan.FromSeconds(5); + + // --- Reading -------------------------------------------------------------------------- + + /// All events in a -identified stream, through the store's read-only view. + public static Task> FetchStreamAsync(IEventStore store, Guid streamId, CancellationToken token = default) + => fetchStreamAsync(store, reader => reader.FetchStreamAsync(streamId, token: token)); + + /// All events in a string-keyed stream, through the store's read-only view. + public static Task> FetchStreamAsync(IEventStore store, string streamKey, CancellationToken token = default) + => fetchStreamAsync(store, reader => reader.FetchStreamAsync(streamKey, token: token)); + + /// + /// bobcat#536. All events in a -identified stream as sees it, read + /// through a session opened for that tenant on the database that holds it (). + /// A null tenant is . + /// + public static Task> FetchStreamAsync(IEventStore store, Guid streamId, string? tenantId, + CancellationToken token = default) + => tenantId is null + ? FetchStreamAsync(store, streamId, token) + : inTenantAsync(store, tenantId, events => events.FetchStreamAsync(streamId, token: token)); + + /// + public static Task> FetchStreamAsync(IEventStore store, string streamKey, string? tenantId, + CancellationToken token = default) + => tenantId is null + ? FetchStreamAsync(store, streamKey, token) + : inTenantAsync(store, tenantId, events => events.FetchStreamAsync(streamKey, token: token)); + + /// + /// Every event in the store at or after , across all streams + /// (issue #319). + /// + /// + /// The escape for a slice whose act CREATES a stream. Every other assertion reads one stream, + /// because the scenario named it — but a creating slice's stream id is the handler's to mint, + /// so there is nothing to name and nothing to read. A sequence floor taken before the act and + /// queried after is what the store can answer instead. + /// + /// Whole-store rather than per-stream, so it is only ever used where the alternative is + /// nothing at all: reaches for it exactly when no stream was + /// bracketed, which today yields an empty list and an assertion that cannot fail. + /// + public static Task> QueryEventsSinceAsync( + IEventStore store, long sequenceFloor, CancellationToken token = default) + => fetchStreamAsync(store, async reader => + { + // PageSize is deliberately large rather than paged: this only ever runs against a + // scenario's own store, whose contents are what that scenario arranged. A silent + // truncation here would read as "the slice emitted nothing", which is the failure this + // whole method exists to stop telling. + var page = await reader + .QueryEventsAsync(new EventQuery { SequenceFloor = sequenceFloor, PageSize = int.MaxValue }, token) + .ConfigureAwait(false); + + return (IReadOnlyList)page.Events.ToList(); + }); + + /// + /// The highest sequence the store has issued, or 0 for an empty store — the mark + /// counts from. + /// + /// + /// Two small reads through the same the act's events are + /// read back with, rather than loading every event: a one-event page for the count, then the last one-event + /// page, which the sequence-ascending ordering contract makes the highest. Not + /// IEventDatabase.FetchHighestEventSequenceNumber, which is per database (a multi-tenant store has + /// several, and the floor must come from the one the read-back reads) and not implemented by every + /// Marten database. Concurrent appends between the two reads can only lower the floor, which counts them + /// as the act's — the whole-store caveat the act already carries. + /// + public static Task HighWaterSequenceAsync(IEventStore store, CancellationToken token = default) + => readAsync(store, async reader => + { + var first = await reader.QueryEventsAsync(new EventQuery { PageSize = 1 }, token).ConfigureAwait(false); + if (first.TotalCount == 0) return 0L; + + var last = await reader.QueryEventsAsync(new EventQuery { PageSize = 1, PageNumber = first.TotalCount }, token) + .ConfigureAwait(false); + return last.Events.Count == 0 ? first.Events.Max(x => x.Sequence) : last.Events.Max(x => x.Sequence); + }); + + /// + /// A -identified stream's state — version and the aggregate type the store recorded for + /// it — or null when there is no such stream. + /// + public static Task FetchStreamStateAsync(IEventStore store, Guid streamId, CancellationToken token = default) + => readAsync(store, reader => reader.FetchStreamStateAsync(streamId, token)); + + /// + public static Task FetchStreamStateAsync(IEventStore store, string streamKey, CancellationToken token = default) + => readAsync(store, reader => reader.FetchStreamStateAsync(streamKey, token)); + + /// Rebuild the aggregate of a -identified stream from its events. + /// + /// JasperFx/wolverine#4865. Project over the events selects + /// by their tags -- a Dynamic Consistency Boundary model, or a view built the same way -- as + /// AggregateByTagsAsync<T> does on Marten, Polecat and Fisher. Null when no event matches. + /// + public static Task AggregateByTagsAsync(IEventStore store, EventTagQuery query, CancellationToken token = default) + where T : class + => AggregateByTagsAsync(store, query, null, token); + + /// + /// bobcat#536. over + /// 's events. A null tenant is the store's default session. + /// + public static async Task AggregateByTagsAsync(IEventStore store, EventTagQuery query, string? tenantId, + CancellationToken token = default) + where T : class + { + var session = await EventStoreSessions.OpenAsync(store, tenantId, token).ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + return await EventStoreSessions.EventStoreOperationsOf(session).AggregateByTagsAsync(query, token).ConfigureAwait(false); + } + } + + /// + /// as a specification reads it: events tagged theScreeningId or CustomerId(CUST-104), + /// with the event types a condition narrows to, (ScreeningScheduled, SeatReserved). + /// + public static string Describe(EventTagQuery query) + { + var groups = query.Conditions + .GroupBy(x => x.TagValue) + .Select(group => + { + var types = group.Where(x => x.EventType is not null).Select(x => x.EventType!.Name).Distinct().ToList(); + var tag = Bobcat.Engine.ScenarioValues.Format(group.Key); + return types.Count == 0 ? tag : $"{tag} ({string.Join(", ", types)})"; + }) + .ToList(); + + return groups.Count == 0 ? "no events" : $"events tagged {string.Join(" or ", groups)}"; + } + + public static Task AggregateStreamAsync(IEventStore store, Guid streamId, CancellationToken token = default) where T : class + => aggregateStreamAsync(store, events => events.AggregateStreamAsync(streamId, token: token)); + + /// Rebuild the aggregate of a string-keyed stream from its events. + public static Task AggregateStreamAsync(IEventStore store, string streamKey, CancellationToken token = default) where T : class + => aggregateStreamAsync(store, events => events.AggregateStreamAsync(streamKey, token: token)); + + /// + /// bobcat#536. Rebuild the aggregate of a -identified stream from 's + /// events. A null tenant is . + /// + public static Task AggregateStreamAsync(IEventStore store, Guid streamId, string? tenantId, + CancellationToken token = default) where T : class + => tenantId is null + ? AggregateStreamAsync(store, streamId, token) + : inTenantAsync(store, tenantId, events => events.AggregateStreamAsync(streamId, token: token)); + + /// + public static Task AggregateStreamAsync(IEventStore store, string streamKey, string? tenantId, + CancellationToken token = default) where T : class + => tenantId is null + ? AggregateStreamAsync(store, streamKey, token) + : inTenantAsync(store, tenantId, events => events.AggregateStreamAsync(streamKey, token: token)); + + /// + /// The aggregate of a -identified stream as the store serves it, through + /// FetchLatest<T> (wolverine#4921): a projected snapshot where there is one, else built from + /// the stream, whatever the projection lifecycle. Null when there is no such stream. + /// + public static Task FetchLatestAsync(IEventStore store, Guid streamId, CancellationToken token = default) where T : class + => fetchLatestAsync(store, null, events => events.FetchLatest(streamId, token), token); + + /// + public static Task FetchLatestAsync(IEventStore store, string streamKey, CancellationToken token = default) where T : class + => fetchLatestAsync(store, null, events => events.FetchLatest(streamKey, token), token); + + /// + /// bobcat#536. as + /// sees it. A null tenant is the store's default session. + /// + public static Task FetchLatestAsync(IEventStore store, Guid streamId, string? tenantId, + CancellationToken token = default) where T : class + => fetchLatestAsync(store, tenantId, events => events.FetchLatest(streamId, token), token); + + /// + public static Task FetchLatestAsync(IEventStore store, string streamKey, string? tenantId, + CancellationToken token = default) where T : class + => fetchLatestAsync(store, tenantId, events => events.FetchLatest(streamKey, token), token); + + /// + /// bobcat#536. A session on scoped to , opened on the database + /// that tenant lives in: the store's own answer through , then + /// Marten's Storage.FindOrCreateDatabase(tenantId), then the store's only database. A store whose tenants + /// are spread over several databases and that can name none of them for this tenant is refused, never guessed at. + /// Dispose it; the object is the store's own session type (a Marten or Fisher session). + /// + public static Task OpenTenantSessionAsync(IEventStore store, string tenantId, CancellationToken token = default) + { + ArgumentException.ThrowIfNullOrEmpty(tenantId); + return EventStoreSessions.OpenAsync(store, tenantId, token); + } + + private static async Task inTenantAsync(IEventStore store, string tenantId, Func> read) + { + var session = await EventStoreSessions.OpenAsync(store, tenantId).ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + return await read(EventStoreSessions.QueryEventStoreOf(session)).ConfigureAwait(false); + } + } + + private static async Task fetchLatestAsync(IEventStore store, string? tenantId, + Func> fetch, CancellationToken token) + where T : class + { + var session = await EventStoreSessions.OpenAsync(store, tenantId, token).ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + return await fetch(EventStoreSessions.EventStoreOperationsOf(session)).ConfigureAwait(false); + } + } + + private static Task> fetchStreamAsync( + IEventStore store, + Func>> read) + => readAsync(store, read); + + private static Task readAsync(IEventStore store, Func> read) + => readAsync(store, null, read); + + private static async Task readAsync(IEventStore store, string? tenantId, Func> read) + { + var reader = tenantId is null ? store.OpenReadOnlyEventStore() : store.OpenReadOnlyEventStore(tenantId); + try + { + return await read(reader).ConfigureAwait(false); + } + finally + { + await disposeIfOwned(reader).ConfigureAwait(false); + } + } + + private static async Task aggregateStreamAsync(IEventStore store, Func> aggregate) + where T : class + { + // Marten and Polecat: the read-only view IS the session's IQueryEventStore, so no reflection. + var reader = store.OpenReadOnlyEventStore(); + try + { + if (reader is IQueryEventStore querying) + return await aggregate(querying).ConfigureAwait(false); + } + finally + { + await disposeIfOwned(reader).ConfigureAwait(false); + } + + // Fisher (and any store whose read-only view is a separate object): open a real session + // through the generic closure and aggregate through its Events member. + var session = await EventStoreSessions.OpenAsync(store).ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + return await aggregate(EventStoreSessions.QueryEventStoreOf(session)).ConfigureAwait(false); + } + } + + // --- Projections ---------------------------------------------------------------------- + + /// + /// Block until every asynchronous projection on every database of the store has caught up with + /// the store's high-water mark, or elapses. Returns at once when the + /// store has no async projections. This is the same wait ProjectionScenario performs after + /// each batch of appends, reached through + /// so it does not matter who runs the daemon — Marten's coordinator, Fisher's hosted service, or + /// Wolverine. + /// + /// + /// Optional fallback for a store that does not enumerate its databases through + /// : the host's , whose + /// main-database daemon is asked to instead. + /// + /// + /// A single-database store with no events at all returns at once (bobcat#497): nothing can be stale. + /// Marten's own wait does not see it that way — a fresh mt_events_sequence reports a last value of 1 + /// with no events, and its WaitForNonStaleProjectionDataAsync answers that with an immediate + /// ("No event activity was detected within the timeout span"). So the first + /// read in a fresh schema threw while every later one passed, which made a document read depend on whether an + /// earlier test had appended anything. + /// + public static async Task WaitForNonStaleProjectionsAsync( + IEventStore store, + TimeSpan? timeout = null, + IProjectionCoordinator? coordinator = null, + CancellationToken token = default) + { + var wait = timeout ?? DefaultProjectionTimeout; + var databases = await store.AllDatabases().ConfigureAwait(false); + + // The read-only view reads the one database, so its emptiness is that database's. Several databases + // are each waited on, as before: the default view would speak for only one of them + if (databases.Count == 1 && await holdsNoEventsAsync(store, token).ConfigureAwait(false)) return; + + if (databases.Count > 0) + { + foreach (var database in databases) + await database.WaitForNonStaleProjectionDataAsync(wait).WaitAsync(token).ConfigureAwait(false); + + return; + } + + if (coordinator != null) + { + await coordinator.DaemonForMainDatabase().WaitForNonStaleData(wait).WaitAsync(token).ConfigureAwait(false); + return; + } + + throw new InvalidOperationException( + $"Event store {Describe(store)} enumerates no databases through IEventStore.AllDatabases() and the host " + + "registers no IProjectionCoordinator, so there is nothing to wait on. Marten, Polecat and Fisher all " + + "enumerate their databases; if this is another store, register its IProjectionCoordinator."); + } + + /// + /// + /// for one tenant: the database that tenant's events live in. A store that can say which database that is waits + /// on that one; any other waits on every database, which holds the tenant's too. + /// + /// + /// The store is asked first, store-agnostically, through + /// (jasperfx#1005, wolverine#4962): its default answers a single-database store, and Marten, Polecat and Fisher + /// are expected to override it for database-per-tenant. When it says null, Marten's + /// Storage.FindOrCreateDatabase(tenantId) is still tried by reflection, so a Marten build that predates the + /// override keeps waiting on one tenant's database; then every database. + /// + public static async Task WaitForNonStaleProjectionsAsync( + IEventStore store, + string tenantId, + TimeSpan? timeout = null, + CancellationToken token = default) + { + ArgumentException.ThrowIfNullOrEmpty(tenantId); + + // #497's empty-store rule, for one tenant: Marten throws at once ("No event activity was detected") on a + // database holding no events, so a tenant's first read in a fresh schema failed whenever no earlier test had + // appended anything (GH-5082 found it once these tests ran in another order). A tenant holding no events has + // no projected data to wait for. Asked of the TENANT's view: with conjoined tenancy the default view reads + // the default tenant only, and would call a database empty that holds another tenant's events. Kept to a + // single database, where that view is known to be the tenant's; database-per-tenant waits as before. + var databases = await store.AllDatabases().ConfigureAwait(false); + if (databases.Count == 1 && await holdsNoEventsAsync(store, tenantId, token).ConfigureAwait(false)) return; + + if (await TenantDatabaseAsync(store, tenantId, token).ConfigureAwait(false) is { } database) + { + await database.WaitForNonStaleProjectionDataAsync(timeout ?? DefaultProjectionTimeout).WaitAsync(token).ConfigureAwait(false); + return; + } + + await WaitForNonStaleProjectionsAsync(store, timeout, null, token).ConfigureAwait(false); + } + + // True only when the store positively says it holds no events. Anything it cannot answer is "not known to be + // empty", so the wait goes ahead as it always did + private static Task holdsNoEventsAsync(IEventStore store, CancellationToken token) + => holdsNoEventsAsync(store, null, token); + + private static async Task holdsNoEventsAsync(IEventStore store, string? tenantId, CancellationToken token) + { + try + { + return await readAsync(store, tenantId, async reader => + { + var page = await reader.QueryEventsAsync(new EventQuery { PageSize = 1 }, token).ConfigureAwait(false); + return page is { TotalCount: 0 }; + }).ConfigureAwait(false); + } + catch (Exception) + { + return false; + } + } + + /// + /// bobcat#497. The async projection shards that had not reached the store's newest event, best effort: each as + /// 'shard' at N of M, a configured shard with no progress yet at 0. Empty when the store cannot say — + /// it is a diagnosis for a timeout's message, never a decision. + /// also says why the store could not say. + /// + /// + /// N is the shard's progress and M the database's highest event sequence number, not a count of events: + /// the sequence is never reset by deleting event data (Marten's DeleteAllEventDataAsync, say), so 67 + /// events published after a wipe can read as at 0 of 461. + /// + public static async Task> ProjectionsBehindAsync(IEventStore store, CancellationToken token = default) + => (await DescribeProjectionsBehindAsync(store, token).ConfigureAwait(false)).Shards; + + /// + /// bobcat#686. , plus the exception that stopped the store saying — a + /// schema dropped by another process, a connection refused — instead of discarding it. Never throws. + /// + public static async Task DescribeProjectionsBehindAsync(IEventStore store, CancellationToken token = default) + { + var behind = new List(); + try + { + var configured = EventStoreShards.TryNames(store); + foreach (var database in await store.AllDatabases().ConfigureAwait(false)) + { + // The highest event sequence number, not an event count (see ProjectionsBehindAsync) + var target = await database.FetchHighestEventSequenceNumber(token).ConfigureAwait(false); + var progress = (await database.AllProjectionProgress(token).ConfigureAwait(false)) + .Where(x => !isHighWaterShard(x)) + .ToList(); + + var shards = configured ?? progress.Select(x => x.ShardName).ToList(); + foreach (var shard in shards) + { + var sequence = progress.FirstOrDefault(x => x.ShardName.Equals(shard, StringComparison.OrdinalIgnoreCase))?.Sequence ?? 0L; + if (sequence < target) behind.Add($"'{shard}' at {sequence} of {target}"); + } + } + } + catch (Exception e) + { + // Not knowable from here on; the caller says so, and why + return new ProjectionsBehind(behind, e); + } + + return new ProjectionsBehind(behind, null); + } + + /// + /// bobcat#685. The diagnosis gives its own catch-up timeouts, for any wait: a + /// suite's own WaitForNonStaleProjectionDataAsync, a daemon's WaitForNonStaleData, a shard-state + /// wait, or . + /// Returns a naming the wait, the shards that were behind + /// (, with why the store could not say when it could not), the timeout + /// and, when given, what was about to be read, with + /// as its . An that is + /// already one is returned as it is. + /// + /// + /// + /// catch (TimeoutException e) + /// { + /// throw await EventStores.DiagnoseCatchUpTimeoutAsync(store, "the daemon has caught up", timeout, e); + /// } + /// + /// + public static async Task DiagnoseCatchUpTimeoutAsync( + IEventStore store, + string wait, + TimeSpan timeout, + TimeoutException inner, + Type? readType = null, + object? identity = null, + CancellationToken token = default) + { + if (inner is ProjectionCatchUpTimeoutException diagnosed) return diagnosed; + + var behind = await DescribeProjectionsBehindAsync(store, token).ConfigureAwait(false); + return new ProjectionCatchUpTimeoutException(wait, behind, timeout, readType, identity, inner); + } + + // The database a tenant's events live in: the store's own answer through FindDatabaseForTenantAsync first + // (jasperfx#1005), then Storage.FindOrCreateDatabase(string) where the store has one; null when neither can say + internal static async Task TenantDatabaseAsync(IEventStore store, string tenantId, CancellationToken token) + { + if (await store.FindDatabaseForTenantAsync(tenantId, token).ConfigureAwait(false) is { } found) return found; + + var storage = store.GetType().GetProperty("Storage", BindingFlags.Public | BindingFlags.Instance)?.GetValue(store); + var find = storage?.GetType().GetInterfaces().Prepend(storage.GetType()) + .Select(x => x.GetMethod("FindOrCreateDatabase", [typeof(string)])) + .FirstOrDefault(x => x is not null); + if (storage is null || find is null) return null; + + // ValueTask, awaited through AsTask() since TDatabase is the store's own type + var pending = find.Invoke(storage, [tenantId])!; + var task = (Task)pending.GetType().GetMethod("AsTask")!.Invoke(pending, [])!; + await task.ConfigureAwait(false); + return task.GetType().GetProperty("Result")!.GetValue(task) as IEventDatabase; + } + + /// + /// What kind of projection the store registers for — a single-stream or a + /// multi-stream projection, say — or null when none builds that type. Read off the store's own description + /// of its subscriptions (). + /// + public static async Task ProjectionKindAsync(IEventStore store, Type projected, CancellationToken token = default) + { + var usage = await store.TryCreateUsage(token).ConfigureAwait(false); + var subscription = usage?.Subscriptions.FirstOrDefault(x => + x.AggregateType is { } aggregate && aggregate.FullName == projected.FullName); + return subscription?.SubscriptionType; + } + + /// + /// Wait until the async projection shards whose name contains + /// (default: typeof(T).Name, which matches both a projection class and the view type a + /// Snapshot<T> registers) have reached the store's current highest event sequence. + /// Returns at once when the store has no async shards at all — an inline projection has nothing + /// to wait for — but a name that matches none of the shards that do exist throws rather than + /// passing vacuously. + /// + public static async Task WaitForProjectionAsync( + IEventStore store, + TimeSpan? timeout = null, + string? projectionName = null, + CancellationToken token = default) + { + var wait = timeout ?? DefaultProjectionTimeout; + var name = projectionName ?? typeof(T).Name; + + var shards = EventStoreShards.TryNames(store); + foreach (var database in await store.AllDatabases().ConfigureAwait(false)) + { + var target = await database.FetchHighestEventSequenceNumber(token).ConfigureAwait(false); + await waitForShards(database, shards, name, target, wait, token).ConfigureAwait(false); + } + } + + /// + /// Wait until the async projection shards whose name contains + /// (default: typeof(T).Name) have processed at least event sequence + /// . Same matching and vacuity rules as + /// . + /// + public static async Task WaitForProjectionAsync( + IEventStore store, + long minSequence, + TimeSpan? timeout = null, + string? projectionName = null, + CancellationToken token = default) + { + var wait = timeout ?? DefaultProjectionTimeout; + var name = projectionName ?? typeof(T).Name; + + var shards = EventStoreShards.TryNames(store); + foreach (var database in await store.AllDatabases().ConfigureAwait(false)) + await waitForShards(database, shards, name, minSequence, wait, token).ConfigureAwait(false); + } + + /// The current progress of every async projection shard, per database. + public static async Task> ProjectionProgressAsync(IEventStore store, CancellationToken token = default) + { + var all = new List(); + foreach (var database in await store.AllDatabases().ConfigureAwait(false)) + all.AddRange(await database.AllProjectionProgress(token).ConfigureAwait(false)); + + return all; + } + + /// + /// The store's configured async shard identities, when the store's generic closure could be read; + /// null means "unknown", in which case the progress rows are the only evidence there is. + /// + private static async Task waitForShards( + IEventDatabase database, + IReadOnlyList? configured, + string name, + long target, + TimeSpan timeout, + CancellationToken token) + { + // Decide what to wait on from the store's CONFIGURATION where we can, not from the progress + // table: a daemon writes a shard's progress row only after its first batch, so right after + // the first append an empty table is indistinguishable from "no async projections" and a wait + // keyed on it would pass vacuously. + IReadOnlyList? expected = null; + if (configured != null) + { + if (configured.Count == 0) + return; // no async shards at all — nothing can be stale + + expected = configured.Where(n => n.Contains(name, StringComparison.OrdinalIgnoreCase)).ToList(); + if (expected.Count == 0) + throw noShardMatches(database, name, configured); + } + + var interval = TimeSpan.FromMilliseconds(50); + var maxInterval = TimeSpan.FromSeconds(1); + var stopwatch = Stopwatch.StartNew(); + + while (true) + { + var progress = await database.AllProjectionProgress(token).ConfigureAwait(false); + var reported = progress.Where(p => !isHighWaterShard(p)).ToList(); + + IReadOnlyList<(string Shard, long Sequence)> matching; + if (expected != null) + { + // A shard with no row yet has processed nothing. + matching = expected + .Select(shard => (shard, reported.FirstOrDefault(p => p.ShardName.Equals(shard, StringComparison.OrdinalIgnoreCase))?.Sequence ?? 0L)) + .ToList(); + } + else + { + if (reported.Count == 0) + return; // unknown configuration and nothing reported — the most we can say + + matching = reported + .Where(p => p.ShardName.Contains(name, StringComparison.OrdinalIgnoreCase)) + .Select(p => (p.ShardName, p.Sequence)) + .ToList(); + + if (matching.Count == 0) + throw noShardMatches(database, name, reported.Select(p => p.ShardName).ToList()); + } + + if (matching.All(p => p.Sequence >= target)) + return; + + if (stopwatch.Elapsed >= timeout) + { + var state = string.Join(", ", matching.Select(p => $"'{p.Shard}' at {p.Sequence}")); + throw new TimeoutException( + $"Projection '{name}' did not reach sequence {target} within {timeout.TotalMilliseconds}ms ({state})."); + } + + await Task.Delay(interval, token).ConfigureAwait(false); + interval = TimeSpan.FromMilliseconds(Math.Min(interval.TotalMilliseconds * 2, maxInterval.TotalMilliseconds)); + } + } + + private static InvalidOperationException noShardMatches(IEventDatabase database, string name, IReadOnlyList known) + => new( + $"No async projection shard on database '{database.Identifier}' matches '{name}'. Known shards: " + + string.Join(", ", known.Select(s => $"'{s}'")) + + ". Pass projectionName explicitly if the projection is not named after its type."); + + // The high-water mark is itself reported as a shard by Marten; it is the thing being waited FOR, + // never a thing to wait ON. + private static bool isHighWaterShard(ShardState state) + => string.Equals(state.ShardName, ShardState.HighWaterMark, StringComparison.OrdinalIgnoreCase); + + // --- Resetting ------------------------------------------------------------------------ + + /// + /// Delete every event and every document in the store, keeping its schema — the between-scenario + /// reset. Follows the convention the Critter Stack stores share (Advanced.ResetAllData, + /// or Advanced.Clean.DeleteAllEventDataAsync + DeleteAllDocumentsAsync); see the + /// class remarks for why this is a convention rather than an interface today. + /// + public static Task ResetAllDataAsync(IEventStore store, CancellationToken token = default) + => StoreResetConvention.ResetAsync(store, token); + + // --- Plumbing --------------------------------------------------------------------------- + + internal static string Describe(IEventStore store) + { + try + { + return $"'{store.Identity.Name}' ({store.Identity.Type}, {store.GetType().Name})"; + } + catch + { + return $"of type {store.GetType().FullName}"; + } + } + + private static ValueTask disposeIfOwned(object? reader) => reader switch + { + IAsyncDisposable asyncDisposable => asyncDisposable.DisposeAsync(), + IDisposable disposable => dispose(disposable), + _ => ValueTask.CompletedTask + }; + + private static ValueTask dispose(IDisposable disposable) + { + disposable.Dispose(); + return ValueTask.CompletedTask; + } +} + +/// +/// Opens a store session through the IEventStore<TOperations, TQuerySession> closure +/// without knowing the closure at compile time, and finds the session's +/// . The one reflective seam in the package; see +/// remarks. +/// +internal static class EventStoreSessions +{ + private static readonly Dictionary openSessionByStoreType = new(); + private static readonly Dictionary openTenantSessionByStoreType = new(); + private static readonly Dictionary eventsBySessionType = new(); + private static readonly Lock gate = new(); + + public static Task OpenAsync(IEventStore store) => OpenAsync(store, null); + + // bobcat#536: a tenant's session is opened on the database that tenant lives in, through the closure's + // OpenSession(IEventDatabase, string tenantId); no tenant is the first database's default session, as before + public static async Task OpenAsync(IEventStore store, string? tenantId, CancellationToken token = default) + { + var open = openSessionMethod(store.GetType(), tenantId is not null); + + var databases = await store.AllDatabases().ConfigureAwait(false); + if (databases.Count == 0) + { + throw new InvalidOperationException( + $"Event store {EventStores.Describe(store)} enumerates no databases through IEventStore.AllDatabases(), " + + "so no session can be opened against it to aggregate a stream."); + } + + var session = tenantId is null + ? open.Invoke(store, [databases[0]]) + : open.Invoke(store, [await tenantDatabaseAsync(store, tenantId, databases, token).ConfigureAwait(false), tenantId]); + if (session is null) throw new InvalidOperationException($"OpenSession on {store.GetType().Name} returned null."); + + return session as IAsyncDisposable + ?? throw new InvalidOperationException( + $"The session type {session.GetType().Name} opened by {store.GetType().Name} is not IAsyncDisposable, " + + "which every IStorageOperations is expected to be."); + } + + public static IEventStoreOperations EventStoreOperationsOf(object session) + { + if (session is IEventStoreOperations direct) return direct; + + if (eventsProperty(session.GetType())?.GetValue(session) is IEventStoreOperations events) return events; + + throw new InvalidOperationException( + $"Cannot fetch the latest aggregate through session type {session.GetType().FullName}: its 'Events' member is not a " + + "JasperFx.Events.IEventStoreOperations. Marten, Polecat and Fisher sessions all expose one."); + } + + public static IQueryEventStore QueryEventStoreOf(object session) + { + if (session is IQueryEventStore direct) + return direct; + + var property = eventsProperty(session.GetType()); + var value = property?.GetValue(session); + if (value is IQueryEventStore events) + return events; + + throw new InvalidOperationException( + $"Cannot aggregate a stream through session type {session.GetType().FullName}: it is not a " + + "JasperFx.Events.IQueryEventStore and has no public 'Events' member that is one. Marten, Polecat and Fisher " + + "sessions all expose one; another store needs IEventStore.OpenReadOnlyEventStore() to return an IQueryEventStore."); + } + + // The tenant's database, or the store's only one. Several databases and no answer is refused: the first one + // would be a guess, and a wrong guess reads or writes another tenant's database without a word + private static async Task tenantDatabaseAsync(IEventStore store, string tenantId, + IReadOnlyList databases, CancellationToken token) + { + if (await EventStores.TenantDatabaseAsync(store, tenantId, token).ConfigureAwait(false) is { } found) return found; + if (databases.Count == 1) return databases[0]; + + throw new InvalidOperationException( + $"Event store {EventStores.Describe(store)} has {databases.Count} databases and cannot say which one holds tenant " + + $"'{tenantId}': IEventStore.FindDatabaseForTenantAsync returned null and the store has no " + + "Storage.FindOrCreateDatabase(string). Is the tenant id right, and is the tenant registered with the store?"); + } + + private static MethodInfo openSessionMethod(Type storeType, bool forTenant) + { + lock (gate) + { + var cache = forTenant ? openTenantSessionByStoreType : openSessionByStoreType; + if (cache.TryGetValue(storeType, out var cached)) + return cached; + + var closure = storeType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEventStore<,>)) + ?? throw new InvalidOperationException( + $"Event store type {storeType.FullName} does not implement IEventStore, " + + "so Bobcat cannot open a session on it to aggregate a stream."); + + Type[] parameters = forTenant ? [typeof(IEventDatabase), typeof(string)] : [typeof(IEventDatabase)]; + var method = closure.GetMethods() + .First(m => m.Name == nameof(IEventStore.OpenSession) + && m.GetParameters().Select(x => x.ParameterType).SequenceEqual(parameters)); + + cache[storeType] = method; + return method; + } + } + + private static PropertyInfo? eventsProperty(Type sessionType) + { + lock (gate) + { + if (eventsBySessionType.TryGetValue(sessionType, out var cached)) + return cached; + + // The session's own public surface first, then every interface it implements — Fisher's + // IQueryEventStore-typed Events is an explicit interface implementation. + var candidates = new[] { sessionType }.Concat(sessionType.GetInterfaces()); + var property = candidates + .SelectMany(t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .FirstOrDefault(p => p.Name == "Events" && typeof(IQueryEventStore).IsAssignableFrom(p.PropertyType)) + ?? candidates + .SelectMany(t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .FirstOrDefault(p => p.Name == "Events" && p.GetIndexParameters().Length == 0); + + eventsBySessionType[sessionType] = property; + return property; + } + } +} + +/// +/// Reads the store's configured async shard identities through the +/// IEventStore<TOperations, TQuerySession>.AllShards() closure, so a projection wait can +/// tell "no async projections" from "the daemon has not written a progress row yet". Same bounded +/// reflection as . +/// +internal static class EventStoreShards +{ + private static readonly Dictionary allShardsByStoreType = new(); + private static readonly Lock gate = new(); + + /// Shard identities, or null when the store does not expose the generic closure. + public static IReadOnlyList? TryNames(IEventStore store) + { + var allShards = allShardsMethod(store.GetType()); + if (allShards == null) + return null; + + if (allShards.Invoke(store, []) is not System.Collections.IEnumerable shards) + return null; + + var names = new List(); + foreach (var shard in shards) + { + if (shard == null) continue; + var name = shard.GetType().GetProperty("Name", BindingFlags.Public | BindingFlags.Instance)?.GetValue(shard); + if (name is ShardName shardName) + names.Add(shardName.Identity); + } + + return names; + } + + private static MethodInfo? allShardsMethod(Type storeType) + { + lock (gate) + { + if (allShardsByStoreType.TryGetValue(storeType, out var cached)) + return cached; + + var closure = storeType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEventStore<,>)); + + var method = closure?.GetMethods() + .FirstOrDefault(m => m.Name == nameof(IEventStore.AllShards) && m.GetParameters().Length == 0); + + allShardsByStoreType[storeType] = method; + return method; + } + } +} + +/// +/// The reset convention shared by Marten, Polecat and Fisher, reached without a reference to any +/// of them. Internal so the lookup is unit-testable against a plain fake; see +/// . +/// +internal static class StoreResetConvention +{ + private static readonly string[] resetMethodNames = ["ResetAllData", "ResetAllDataAsync"]; + + public static async Task ResetAsync(object store, CancellationToken token) + { + var advanced = store.GetType().GetProperty("Advanced", BindingFlags.Public | BindingFlags.Instance)?.GetValue(store); + if (advanced != null) + { + var reset = resetMethodNames + .Select(name => advanced.GetType().GetMethod(name, BindingFlags.Public | BindingFlags.Instance, [typeof(CancellationToken)])) + .FirstOrDefault(m => m != null && typeof(Task).IsAssignableFrom(m.ReturnType)); + + if (reset != null) + { + await ((Task)reset.Invoke(advanced, [token])!).ConfigureAwait(false); + return; + } + + var cleaner = advanced.GetType().GetProperty("Clean", BindingFlags.Public | BindingFlags.Instance)?.GetValue(advanced); + if (cleaner != null && tryFind(cleaner, "DeleteAllEventDataAsync", out var events) && tryFind(cleaner, "DeleteAllDocumentsAsync", out var documents)) + { + await events(token).ConfigureAwait(false); + await documents(token).ConfigureAwait(false); + return; + } + } + + throw new InvalidOperationException( + $"Bobcat does not know how to reset event store type {store.GetType().FullName}. It looked for " + + "Advanced.ResetAllData(CancellationToken) / Advanced.ResetAllDataAsync(CancellationToken), then for " + + "Advanced.Clean.DeleteAllEventDataAsync() and DeleteAllDocumentsAsync() — the convention Marten, Polecat " + + "and Fisher share. Reset this store in the host resource's reset hook instead."); + } + + private static bool tryFind(object target, string name, out Func call) + { + var withToken = target.GetType().GetMethod(name, BindingFlags.Public | BindingFlags.Instance, [typeof(CancellationToken)]); + if (withToken != null && typeof(Task).IsAssignableFrom(withToken.ReturnType)) + { + call = token => (Task)withToken.Invoke(target, [token])!; + return true; + } + + var bare = target.GetType().GetMethod(name, BindingFlags.Public | BindingFlags.Instance, Type.EmptyTypes); + if (bare != null && typeof(Task).IsAssignableFrom(bare.ReturnType)) + { + call = _ => (Task)bare.Invoke(target, [])!; + return true; + } + + call = null!; + return false; + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/ProjectionCatchUpTimeoutException.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/ProjectionCatchUpTimeoutException.cs new file mode 100644 index 000000000..4071f1b16 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/ProjectionCatchUpTimeoutException.cs @@ -0,0 +1,84 @@ +namespace Bobcat.CritterStack; + +/// +/// bobcat#497. The wait for the async projections to catch up, before a read such as +/// , ran out of time. An error, not a specification +/// failure: a daemon that is behind, stopped or not running is the environment, not evidence that the document is +/// wrong. The message names the wait, the projections that were behind (when the store can say), the timeout and +/// what was being read; the store's own exception is the . +/// +/// +/// A , so code that caught the store's timeout before still catches this one. A wait +/// that reads nothing afterwards (, a shard-level +/// wait in a suite's own code) has no (bobcat#685). Any suite builds one for its own wait +/// through . +/// +public sealed class ProjectionCatchUpTimeoutException : TimeoutException +{ + public ProjectionCatchUpTimeoutException(string wait, IReadOnlyList behind, TimeSpan timeout, Type? readType, + object? identity, Exception inner) + : this(wait, new ProjectionsBehind(behind, null), timeout, readType, identity, inner) + { + } + + /// + /// bobcat#686. As above, carrying why the store could not say which projections were behind + /// () into the message and . + /// + public ProjectionCatchUpTimeoutException(string wait, ProjectionsBehind behind, TimeSpan timeout, Type? readType, + object? identity, Exception inner) + : base(describe(wait, behind, timeout, readType, identity, inner), inner) + { + Wait = wait; + Behind = behind.Shards; + BehindFailure = behind.Failure; + Timeout = timeout; + ReadType = readType; + Identity = identity; + } + + /// The wait that timed out, as the step that ran it reads. + public string Wait { get; } + + /// + /// The projection shards that had not caught up, as 'shard' at N of M; empty when the store could not say. + /// N is the shard's progress and M the highest event sequence number, not a count of events: deleting + /// event data does not reset the sequence, so a few events after a wipe can read as at 0 of 461. + /// + public IReadOnlyList Behind { get; } + + /// + /// bobcat#686. The exception that stopped the store saying which projections were behind (a schema another + /// process dropped, say), or null when it said all it could. + /// + public Exception? BehindFailure { get; } + + /// How long the wait was given (). + public TimeSpan Timeout { get; } + + /// The type the read after the wait was for, or null for a wait that reads nothing afterwards. + public Type? ReadType { get; } + + /// The identity being read, or null for a read with none (every document of a type, the scenario's stream). + public object? Identity { get; } + + private static string describe(string wait, ProjectionsBehind behind, TimeSpan timeout, Type? readType, object? identity, + Exception inner) + { + var reading = readType is null + ? "" + : identity is null ? $", before reading the {readType.Name}" : $", before reading the {readType.Name} with id {identity}"; + var about = readType is null ? "any read model" : $"the {readType.Name} itself"; + var why = behind.Failure is { } failure ? $" ({failure.GetType().Name}: {failure.Message})" : ""; + var shards = (behind.Shards.Count, behind.Failure) switch + { + (0, null) => "The store could not say which projection was behind.", + (0, _) => $"The store could not say which projection was behind{why}.", + (_, null) => $"Behind: {string.Join(", ", behind.Shards)}.", + _ => $"Behind: {string.Join(", ", behind.Shards)}; the rest could not be read{why}." + }; + return $"Waiting for '{wait}' timed out after {timeout.TotalSeconds:0.###}s{reading}. {shards} " + + $"This is the environment (a daemon behind, stopped or not running), not evidence about {about}. " + + $"The store said: {inner.GetType().Name}: {inner.Message}"; + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/ProjectionsBehind.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/ProjectionsBehind.cs new file mode 100644 index 000000000..b57c468ce --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/ProjectionsBehind.cs @@ -0,0 +1,8 @@ +namespace Bobcat.CritterStack; + +/// +/// bobcat#686. What could find out: the shards that had not +/// caught up, as 'shard' at N of M (M is the highest event sequence number, not an event count), and the +/// exception that stopped the store saying more, or null when it said everything it could. +/// +public sealed record ProjectionsBehind(IReadOnlyList Shards, Exception? Failure); diff --git a/src/Bobcat/Wolverine.Bobcat/CritterStack/StoreRouting.cs b/src/Bobcat/Wolverine.Bobcat/CritterStack/StoreRouting.cs new file mode 100644 index 000000000..91d7d523e --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/CritterStack/StoreRouting.cs @@ -0,0 +1,205 @@ +using JasperFx.Events; +using JasperFx.Events.Descriptors; + +namespace Bobcat.CritterStack; + +/// +/// bobcat#504. One store's high-water mark, taken before an act: the highest sequence it had issued, which +/// the act's read-back counts from. See CritterStackScenario.highWaterMarksAsync. +/// +/// The event store the mark was taken on. +/// The highest sequence it had issued before the act, 0 for an empty store. +public record StoreHighWaterMark(IEventStore Store, long Sequence); + +/// +/// bobcat#504. What one event store says it owns, read off its own registrations through the JasperFx +/// abstractions every Critter Stack store implements: (its +/// projections' aggregate and document types and applied events, its registered event types, its DCB tag +/// types and their aggregates) and (its document +/// mappings). Type identity is the full name, as the descriptors carry it. +/// +/// +/// A store that cannot describe itself (it returns null or throws) owns nothing, so routing falls back to the +/// default store rather than failing on the description. Marten, Polecat and Fisher register some types +/// lazily — an event type the first time it is appended, a document type the first time it is stored or +/// loaded — so what a store owns can grow while the process runs; the scenario caches what it read. +/// +internal sealed class StoreCatalog +{ + public static readonly StoreCatalog Empty = new(); + + /// Aggregate, projected and document types: what a model-typed step is routed by. + public HashSet Models { get; } = new(StringComparer.Ordinal); + + /// Event types the store registers, or one of its projections applies. + public HashSet Events { get; } = new(StringComparer.Ordinal); + + /// DCB tag types the store registers. + public HashSet Tags { get; } = new(StringComparer.Ordinal); + + public static async Task ReadAsync(IEventStore store, CancellationToken token = default) + { + var catalog = new StoreCatalog(); + + try + { + if (await store.TryCreateUsage(token).ConfigureAwait(false) is { } usage) catalog.add(usage); + } + catch (Exception) + { + // Nothing the store can say about its events; it owns none of them + } + + try + { + if (store is IDocumentStoreUsageSource documents + && await documents.TryCreateUsage(token).ConfigureAwait(false) is { } documentUsage) + { + foreach (var mapping in documentUsage.Documents) + { + if (mapping.DocumentType?.FullName is { } name) catalog.Models.Add(name); + } + } + } + catch (Exception) + { + // Likewise for its documents + } + + return catalog; + } + + private void add(EventStoreUsage usage) + { + foreach (var subscription in usage.Subscriptions) + { + if (subscription.AggregateType?.FullName is { } aggregate) Models.Add(aggregate); + foreach (var applied in subscription.AppliedEvents) Events.Add(applied.FullName); + foreach (var e in subscription.Events) addEvent(e.Type?.FullName); + } + + foreach (var e in usage.Events) addEvent(e.Type?.FullName); + foreach (var e in usage.RegisteredEventTypes) addEvent(e.EventType?.FullName); + + foreach (var tag in usage.TagTypes) + { + if (tag.TagType is { Length: > 0 } tagType) Tags.Add(tagType); + if (tag.AggregateType is { Length: > 0 } aggregate) Models.Add(aggregate); + } + } + + private void addEvent(string? name) + { + if (name is { Length: > 0 }) Events.Add(name); + } +} + +/// +/// bobcat#504. Picks the event store a step reads or writes when the host registers several: the one store +/// whose registrations claim the step's types, else the default store (the scenario's storeName, else the +/// host's one main store), else a +/// that lists the candidates and says to address one with +/// In<TStore>() or In(name). Never used for a host with a single store. +/// +internal sealed class StoreRouter +{ + private readonly IReadOnlyList _stores; + private readonly IEventStore? _default; + private readonly Dictionary> _catalogs = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _decisions = new(StringComparer.Ordinal); + private readonly Lock _gate = new(); + + public StoreRouter(IReadOnlyList stores, IEventStore? defaultStore) + { + _stores = stores; + _default = defaultStore; + } + + public IReadOnlyList Stores => _stores; + + /// + /// The store for a step about (an aggregate, projected or document type, when the + /// step has one), whose events are and whose tags are . + /// The model decides when exactly one store claims it; the events and tags decide when none does, or + /// narrow several down to one. + /// + public async Task RouteAsync(string step, Type? model, IReadOnlyCollection events, + IReadOnlyCollection tags) + { + var key = $"{model?.FullName}|{string.Join(",", events.Select(x => x.FullName).Distinct().Order())}|" + + string.Join(",", tags.Select(x => x.FullName).Distinct().Order()); + lock (_gate) + { + if (_decisions.TryGetValue(key, out var known)) return known; + } + + var catalogs = new List<(IEventStore Store, StoreCatalog Catalog)>(); + foreach (var store in _stores) catalogs.Add((store, await catalogOf(store).ConfigureAwait(false))); + + var decided = decide(step, model, events, tags, catalogs); + lock (_gate) + { + _decisions[key] = decided; + } + + return decided; + } + + private IEventStore decide(string step, Type? model, IReadOnlyCollection events, IReadOnlyCollection tags, + IReadOnlyList<(IEventStore Store, StoreCatalog Catalog)> catalogs) + { + var byModel = model is null + ? [] + : catalogs.Where(x => x.Catalog.Models.Contains(model.FullName!)).Select(x => x.Store).ToList(); + if (byModel.Count == 1) return byModel[0]; + + // The events and tags: every store that claims any of them + var bySignature = catalogs + .Where(x => events.Any(e => x.Catalog.Events.Contains(e.FullName!)) || tags.Any(t => x.Catalog.Tags.Contains(t.FullName!))) + .Select(x => x.Store) + .ToList(); + + // Several stores claim the model: the events may still say which one + var candidates = byModel.Count > 1 + ? bySignature.Count == 0 ? byModel : byModel.Where(bySignature.Contains).ToList() + : bySignature; + if (candidates.Count == 1) return candidates[0]; + if (byModel.Count > 1 && candidates.Count == 0) candidates = byModel; + + // Nothing claims it, or several stores do: the default store, when it can take it + if (_default is not null && (candidates.Count == 0 || candidates.Contains(_default))) return _default; + + throw new Engine.SpecCriticalException(ambiguity(step, model, events, candidates)); + } + + private string ambiguity(string step, Type? model, IReadOnlyCollection events, IReadOnlyList candidates) + { + var subject = model is not null + ? model.Name + : events.Count > 0 ? string.Join(", ", events.Select(x => x.Name).Distinct()) : "this step"; + + var claim = candidates.Count == 0 + ? $"no event store registers {subject}" + : $"{candidates.Count} event stores register {subject} ({string.Join(", ", candidates.Select(EventStores.Describe))})"; + + return $"'{step}': the host registers {_stores.Count} event stores and {claim}, so Bobcat cannot tell which " + + $"one it means{(_default is null ? " and the scenario names no default store (pass storeName, or register exactly one main store)" : "")}. Address the store explicitly: " + + "scenario.In() for a store registered under a marker interface (AddMartenStore, " + + "AddPolecatStore, AddFisherStore), or scenario.In(\"name\") by its IEventStore.Identity name. " + + $"Stores: {string.Join(", ", _stores.Select(EventStores.Describe))}."; + } + + private Task catalogOf(IEventStore store) + { + lock (_gate) + { + if (!_catalogs.TryGetValue(store, out var catalog)) + { + catalog = StoreCatalog.ReadAsync(store); + _catalogs[store] = catalog; + } + + return catalog; + } + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/EventModel/GeneratedEventModel.cs b/src/Bobcat/Wolverine.Bobcat/EventModel/GeneratedEventModel.cs new file mode 100644 index 000000000..4f8ca1c22 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/EventModel/GeneratedEventModel.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using JasperFx.Events.EventModeling; + +namespace Bobcat; + +/// +/// The Event Model half a spec assembly's generated source declares — the slices its projected +/// specifications describe, and the {Feature}/{Scenario} +/// identities they bind to them (issues #106, #170). +/// +/// +/// +/// Reflection, because the generated type is internal to the spec assembly. That is the +/// whole reason the application host cannot see this half — the reference points the other way — +/// and it is why has to publish it from +/// inside the run. Issue #338 needed the same fact for a second reason (checking the identities +/// against the model), so the lookup moved here rather than being copied: one place knows the +/// generated type's name, and if the emitter ever renames it, one place breaks. +/// +/// +/// Looked up by name rather than by scanning for IEventModelDefinitionSource +/// implementations. Scanning would mean constructing arbitrary user types on the off chance +/// one of them is a model source. The generated type is the one thing here that is ours, and the +/// one thing guaranteed to need no services to describe itself. +/// +/// +/// Failures are the caller's to interpret. A missing generated type is a fact, not an +/// error — an assembly whose specs declare no slices has none — so that is a null. Anything else +/// throws, because the two callers want opposite things from it: a run must never be disturbed by +/// a model it could not read, and an audit that swallowed the same failure would report every +/// declared scenario as uncovered. +/// +/// +public static class GeneratedEventModel +{ + /// + /// The one type the generator emits per spec assembly — see + /// EventModelEmitter.EmitSource, which writes this namespace and class name. + /// + public const string GeneratedSourceTypeName = "Bobcat.Generated.EventModel.BobcatEventModelSource"; + + /// + /// The assembly's generated descriptor, provenance-stamped the way + /// EventModelDiscovery would stamp it, or null when the assembly has no generated + /// source at all. + /// + /// + /// A spec assembly's slices are Declared, and the merge upstream decides winning claims by + /// provenance — so an unstamped half would lose to itself. + /// + public static EventModelDescriptor? For(Assembly assembly) + { + var type = assembly.GetType(GeneratedSourceTypeName, throwOnError: false); + if (type is null) return null; + + // The generated type and its Instance field are internal to the spec assembly. + var instance = type + .GetField("Instance", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) + ?.GetValue(null) as IEventModelDefinitionSource; + + instance ??= Activator.CreateInstance(type, nonPublic: true) as IEventModelDefinitionSource; + if (instance is null) return null; + + // The generated implementation ignores the service provider entirely — it is all + // compile-time fact — and neither caller has a container to offer one. + // Synchronous by design: both callers are synchronous, and the generated source completes at once. +#pragma warning disable VSTHRD002 + var descriptor = instance + .TryCreateAsync(EmptyServiceProvider.Instance, CancellationToken.None) + .GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + + return descriptor?.WithProvenance(instance.Provenance); + } + + /// Stands in for the container neither caller has. + private sealed class EmptyServiceProvider : IServiceProvider + { + public static readonly EmptyServiceProvider Instance = new(); + + public object? GetService(Type serviceType) => null; + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/EventModel/SpecIdentityAudit.cs b/src/Bobcat/Wolverine.Bobcat/EventModel/SpecIdentityAudit.cs new file mode 100644 index 000000000..1e667e822 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/EventModel/SpecIdentityAudit.cs @@ -0,0 +1,281 @@ +using System.Reflection; +using System.Text; +using JasperFx.Events.EventModeling; + +namespace Bobcat; + +/// One spec identity and the slice that claims it. +/// The {Feature}/{Scenario} identity. +/// The slice the claim sits on. +/// +/// True when the claim is a pending specification — a scenario with no steps, which +/// upstream carries as a hotspot rather than as a specification (jasperfx#689). The identity is +/// still real and still joins; it simply verifies nothing yet. +/// +public sealed record SpecIdentityClaim(string Identity, string Slice, bool Pending = false); + +/// An identity both sides declare, on different slices. +public sealed record MisboundSpecIdentity(string Identity, string DeclaredOn, string BoundTo); + +/// +/// The join nothing checked (issue #338): every {Feature}/{Scenario} identity the compiled +/// specs declare, against every one the design declares. +/// +/// +/// +/// Why this is a gap and not a user error. Both artifacts already exist, and the join is +/// pure computation — the generated BobcatEventModelSource says what the tests declare, the +/// curated model says what the design declares, and {Feature}/{Scenario} is deliberately +/// the same string on both sides so they join with no mapping table. A projected spec whose +/// scenario nobody designed survived four commits in CritterCrush with a green suite, a clean +/// build, no diagnostic and no warning; the only thing that caught it was a human diffing +/// generated code against the model's scenario names. +/// +/// +/// Two directions, and they are not the same finding. An orphan +/// is a test identity the model does not declare — the canvas shows a specification for something +/// nobody designed. A hole is a model scenario no test covers — the design +/// declares behaviour nothing verifies. is the third, free once both sides +/// are in hand: an identity both declare, bound to different slices, which is a spec pointing at +/// the wrong behaviour on the canvas. +/// +/// +/// Why the existing diagnostics do not cover it. BOBCAT025 and BOBCAT026 +/// compare slice NAMES. A test bound to the right slice under a scenario name nobody designed +/// passes both. +/// +/// +/// Where it runs. Not a build diagnostic: the generator holds the compiled identities but +/// not the curated model, which may not even live in the spec project. It is pure computation over +/// two s, so the spec assembly's own test run is the one place +/// both facts are already loaded — one assertion, and the drift is a red build. The same call +/// answers a Stoat gate, since and are the whole +/// contract. +/// +/// +/// Test identities the model does not declare. +/// Model identities no test covers. +/// Identities both sides declare, on different slices. +/// Identities that join, whose test scenario has no steps. +/// Identities excused by name, with the reason given for each. +/// How many identities joined cleanly. +public sealed record SpecIdentityAudit( + IReadOnlyList Orphans, + IReadOnlyList Holes, + IReadOnlyList Misbound, + IReadOnlyList Pending, + IReadOnlyList> Excused, + int Matched) +{ + /// + /// Whether the two sides disagree about anything. A pending specification is deliberately not + /// drift: the identity joins, and "this scenario has no steps yet" is already a hotspot on the + /// canvas — failing a build for it would make the audit an unrelated policy. + /// + public bool Drifted => Orphans.Count > 0 || Holes.Count > 0 || Misbound.Count > 0; + + /// + /// Compare what the design declares against what the compiled specs declare. + /// + /// + /// The design's model. A curated file becomes one through + /// Bobcat.EventModel.ImportedModelMapper.ToDescriptor. + /// + /// The spec assembly's generated descriptor. + /// + /// Identities to leave out of both directions, each with the reason it is excused — a + /// chapter-wide invariant test that deliberately binds no slice, or a model scenario + /// specified in a lane Bobcat cannot see. Said out loud here rather than inferred from + /// silence, because "deliberately not a spec" is a claim someone should have to make. + /// + public static SpecIdentityAudit Compare( + EventModelDescriptor declared, + EventModelDescriptor tests, + IReadOnlyDictionary? excused = null) + => Compare(ClaimsIn(declared), ClaimsIn(tests), excused); + + /// + /// Compare the design's model against the generated descriptors of one or more spec + /// assemblies — the form a spec assembly's own test uses, with + /// typeof(SomeSpec).Assembly. + /// + /// + /// No assembly passed has a generated Event Model source. That is never a clean result: the + /// audit would report every declared scenario as a hole, which is the same confident lie as + /// zero-filling an unmeasured duration. + /// + public static SpecIdentityAudit Compare( + EventModelDescriptor declared, + IReadOnlyDictionary? excused, + params Assembly[] specAssemblies) + { + if (specAssemblies.Length == 0) + throw new InvalidOperationException("Name at least one spec assembly to audit."); + + var claims = new List(); + var found = new List(); + + foreach (var assembly in specAssemblies) + { + var descriptor = GeneratedEventModel.For(assembly); + if (descriptor is null) continue; + + found.Add(assembly.GetName().Name ?? "?"); + claims.AddRange(ClaimsIn(descriptor)); + } + + if (found.Count == 0) + { + throw new InvalidOperationException( + $"None of {string.Join(", ", specAssemblies.Select(x => x.GetName().Name))} has a generated " + + $"Event Model source ({GeneratedEventModel.GeneratedSourceTypeName}). The generator emits " + + "one only for an assembly whose specs declare slices, so either the assembly named is not " + + "the spec assembly, or no test in it is bound to a slice: bind one with " + + "[BobcatSpec(typeof(TheCommand))] or [BobcatSlice(typeof(TheCommand))]. Auditing against nothing " + + "would report every declared scenario as uncovered."); + } + + return Compare(ClaimsIn(declared), claims, excused); + } + + /// + public static SpecIdentityAudit Compare(EventModelDescriptor declared, Assembly specAssembly) + => Compare(declared, null, specAssembly); + + /// The comparison itself, over claims from wherever they came. + public static SpecIdentityAudit Compare( + IEnumerable declared, + IEnumerable tests, + IReadOnlyDictionary? excused = null) + { + var pardons = excused ?? new Dictionary(); + + // Grouped by identity rather than kept as a flat list: a slice may legitimately be + // described by several features, and one identity may be claimed by more than one slice + // on either side. Comparing the SETS is what makes "bound to the wrong slice" a finding + // instead of a coin toss over which claim was seen first. + var left = group(declared.Where(x => !pardons.ContainsKey(x.Identity))); + var right = group(tests.Where(x => !pardons.ContainsKey(x.Identity))); + + var orphans = new List(); + var holes = new List(); + var misbound = new List(); + var pending = new List(); + var matched = 0; + + foreach (var (identity, claims) in right) + { + if (!left.TryGetValue(identity, out var design)) + { + orphans.AddRange(claims); + continue; + } + + matched++; + pending.AddRange(claims.Where(x => x.Pending)); + + var designSlices = design.Select(x => x.Slice).ToHashSet(StringComparer.Ordinal); + foreach (var claim in claims.Where(x => !designSlices.Contains(x.Slice))) + { + misbound.Add(new MisboundSpecIdentity( + identity, string.Join(", ", designSlices.OrderBy(x => x, StringComparer.Ordinal)), claim.Slice)); + } + } + + foreach (var (identity, claims) in left.Where(x => !right.ContainsKey(x.Key))) + { + holes.AddRange(claims); + } + + return new SpecIdentityAudit( + orphans, holes, misbound, pending, + pardons.OrderBy(x => x.Key, StringComparer.Ordinal).ToList(), + matched); + } + + /// + /// Every identity a descriptor claims: its slices' Specifications, plus the pending + /// ones, which upstream carries as hotspots (HotspotDescriptor.PendingSpecification). + /// + /// + /// Pending identities are collected deliberately. Leaving them out would report a step-less + /// scenario as a hole — "the design declares behaviour nothing verifies" — which reads as a + /// missing test when the test is right there with nothing in it. Two different problems, and + /// the audit's job is identities; the empty body is already a hotspot. + /// + public static IReadOnlyList ClaimsIn(EventModelDescriptor descriptor) + { + var claims = new List(); + + foreach (var slice in descriptor.Slices) + { + foreach (var specification in slice.Specifications) + { + claims.Add(new SpecIdentityClaim(specification.Identity, slice.Name)); + } + + foreach (var hotspot in slice.Hotspots) + { + if (hotspot.Origin != HotspotOrigin.PendingSpecification) continue; + if (hotspot.SpecificationIdentity is not { } identity) continue; + + claims.Add(new SpecIdentityClaim(identity, slice.Name, Pending: true)); + } + } + + return claims; + } + + /// One line per finding, and a count when there is nothing to say. + public string Report() + { + var sb = new StringBuilder(); + + sb.AppendLine(Drifted + ? $"Spec identities: {Matched} matched, {Orphans.Count} orphaned, {Holes.Count} uncovered, {Misbound.Count} misbound." + : $"Spec identities: {Matched} matched, no drift."); + + section(sb, "Orphans — a test declares this identity and the model does not", + Orphans.Select(x => $"{x.Identity} (bound to slice {x.Slice})")); + + section(sb, "Holes — the model declares this identity and no test covers it", + Holes.Select(x => $"{x.Identity} (slice {x.Slice})")); + + section(sb, "Misbound — both declare this identity, on different slices", + Misbound.Select(x => $"{x.Identity} (model: {x.DeclaredOn}; test: {x.BoundTo})")); + + section(sb, "Pending — the identity joins, but the scenario has no steps", + Pending.Select(x => $"{x.Identity} (slice {x.Slice})")); + + section(sb, "Excused", Excused.Select(x => $"{x.Key} — {x.Value}")); + + return sb.ToString(); + } + + private static void section(StringBuilder sb, string title, IEnumerable lines) + { + var rendered = lines.ToList(); + if (rendered.Count == 0) return; + + sb.AppendLine(); + sb.AppendLine(title + ":"); + foreach (var line in rendered) sb.AppendLine(" " + line); + } + + private static Dictionary> group(IEnumerable claims) + { + var grouped = new Dictionary>(StringComparer.Ordinal); + + foreach (var claim in claims) + { + if (!grouped.TryGetValue(claim.Identity, out var list)) + { + grouped[claim.Identity] = list = new List(); + } + + list.Add(claim); + } + + return grouped; + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/PreflightContainerChecks.cs b/src/Bobcat/Wolverine.Bobcat/PreflightContainerChecks.cs new file mode 100644 index 000000000..b5f9af232 --- /dev/null +++ b/src/Bobcat/Wolverine.Bobcat/PreflightContainerChecks.cs @@ -0,0 +1,30 @@ +using Bobcat.Runtime; +using JasperFx.Environment; + +namespace Bobcat; + +/// +/// The container half of a : every check JasperFx knows how to collect from a host's +/// services. It lived on itself until Bobcat core stopped referencing JasperFx +/// (JasperFx/bobcat#744, GH-5082), so it is an extension method now, and a call site reads exactly as it did. +/// +public static class PreflightContainerChecks +{ + /// + /// Adds every check the container knows about — ISystemPart, IStatefulResource + /// and IHealthCheck — by delegating to JasperFx's own executor. The whole set is one + /// preflight check: it fails with JasperFx's , whose message + /// lists every failure, and that message is what reports. + /// + public static Preflight AddContainerChecks(this Preflight preflight, string description, IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(preflight); + ArgumentNullException.ThrowIfNull(services); + + return preflight.Add(description, async token => + { + var results = await EnvironmentChecker.ExecuteAllEnvironmentChecks(services, token); + results.Assert(); // throws EnvironmentCheckException listing every failure + }); + } +} diff --git a/src/Bobcat/Wolverine.Bobcat/Wolverine.Bobcat.csproj b/src/Bobcat/Wolverine.Bobcat/Wolverine.Bobcat.csproj index 2a9cff4a4..f2cff8cc7 100644 --- a/src/Bobcat/Wolverine.Bobcat/Wolverine.Bobcat.csproj +++ b/src/Bobcat/Wolverine.Bobcat/Wolverine.Bobcat.csproj @@ -5,6 +5,10 @@ $(Version)-alpha.1 net9.0;net10.0 + + 13 + + $(NoWarn);CS0419 false false @@ -22,5 +26,7 @@ + + diff --git a/src/Samples/Bobcat/.gitignore b/src/Samples/Bobcat/.gitignore new file mode 100644 index 000000000..e91a94cf9 --- /dev/null +++ b/src/Samples/Bobcat/.gitignore @@ -0,0 +1,403 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# macOS +.DS_Store + +# JetBrains Rider +*.sln.iml +.idea + diff --git a/src/Samples/Bobcat/BankAccountES/Account.cs b/src/Samples/Bobcat/BankAccountES/Account.cs new file mode 100644 index 000000000..ddb654013 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Account.cs @@ -0,0 +1,48 @@ +namespace BankAccountES; + +// --- Domain Events --- + +public record AccountOpened(Guid AccountId, Guid ClientId, string Currency); +public record FundsDeposited(Guid AccountId, decimal Amount, decimal NewBalance); +public record FundsWithdrawn(Guid AccountId, decimal Amount, decimal NewBalance); + +// --- Aggregate (event-sourced; the store is whichever Program.cs registered) --- + +/// +/// Bank account aggregate. The event store rebuilds state by calling the Apply methods +/// when loading from the event stream — Marten and Fisher share that convention through +/// JasperFx.Events. Business rules are enforced in Wolverine handler methods that return events. +/// +public class Account +{ + public Guid Id { get; set; } + public Guid ClientId { get; set; } + public string Currency { get; set; } = "USD"; + public decimal Balance { get; set; } + public bool IsFrozen { get; set; } + + public void Apply(AccountOpened e) + { + Id = e.AccountId; + ClientId = e.ClientId; + Currency = e.Currency; + } + + public void Apply(FundsDeposited e) + { + Balance = e.NewBalance; + } + + public void Apply(FundsWithdrawn e) + { + Balance = e.NewBalance; + } + + public void Apply(AccountFrozen e) + { + IsFrozen = true; + } + + // AccountFlagged deliberately has no Apply — it is an audit marker, and aggregation skips + // events no Apply claims. (It is also bobcat#172's planted disagreement; see FreezeAccount.cs.) +} diff --git a/src/Samples/Bobcat/BankAccountES/BankAccountES.csproj b/src/Samples/Bobcat/BankAccountES/BankAccountES.csproj new file mode 100644 index 000000000..60b45e0ac --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/BankAccountES.csproj @@ -0,0 +1,65 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/BankAccountES/BankAccountES.slnx b/src/Samples/Bobcat/BankAccountES/BankAccountES.slnx new file mode 100644 index 000000000..b8924fc13 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/BankAccountES.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Samples/Bobcat/BankAccountES/Client.cs b/src/Samples/Bobcat/BankAccountES/Client.cs new file mode 100644 index 000000000..3492c1ef6 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Client.cs @@ -0,0 +1,28 @@ +namespace BankAccountES; + +// --- Domain Events --- + +public record ClientEnrolled(Guid ClientId, string Name, string Email); +public record ClientUpdated(Guid ClientId, string Name, string Email); + +// --- Aggregate (event-sourced; the store is whichever Program.cs registered) --- + +public class Client +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + + public void Apply(ClientEnrolled e) + { + Id = e.ClientId; + Name = e.Name; + Email = e.Email; + } + + public void Apply(ClientUpdated e) + { + Name = e.Name; + Email = e.Email; + } +} diff --git a/src/Samples/Bobcat/BankAccountES/DepositFunds.cs b/src/Samples/Bobcat/BankAccountES/DepositFunds.cs new file mode 100644 index 000000000..7939dd05e --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/DepositFunds.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using Wolverine.Http; +using Wolverine.Persistence.EventSourcing; + +namespace BankAccountES; + +public record DepositFunds(Guid AccountId, decimal Amount) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.AccountId).NotEmpty(); + RuleFor(x => x.Amount).GreaterThan(0); + } + } +} + +public static class DepositFundsEndpoint +{ + [WolverinePost("/api/accounts/{accountId}/deposits")] + [DeciderFunction] + public static (IResult, FundsDeposited) Post(DepositFunds command, Account account) + { + var newBalance = account.Balance + command.Amount; + return (Results.NoContent(), new FundsDeposited(command.AccountId, command.Amount, newBalance)); + } +} diff --git a/src/Samples/Bobcat/BankAccountES/EnrollClient.cs b/src/Samples/Bobcat/BankAccountES/EnrollClient.cs new file mode 100644 index 000000000..171da0171 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/EnrollClient.cs @@ -0,0 +1,39 @@ +using FluentValidation; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace BankAccountES; + +public record EnrollClient(string Name, string Email) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + } + } +} + +public static class EnrollClientEndpoint +{ + // Created rather than a bare Client, so the response carries the 201 and Location a + // newly-created resource should. The second tuple member is a Wolverine side effect, not a + // cascaded message — Storage.StartStream() is how a handler starts a stream without naming the + // store. (A (Client, IResult) tuple, by contrast, would be read as (body, cascaded-message) and + // the IResult dispatched as a message with no handler. See docs/sample-wiring.md footgun 3.) + [WolverinePost("/api/clients")] + public static (Created, StartStream) Post(EnrollClient command) + { + var clientId = Guid.NewGuid(); + var evt = new ClientEnrolled(clientId, command.Name, command.Email); + + var client = new Client(); + client.Apply(evt); + return ( + TypedResults.Created($"/api/clients/{clientId}", client), + Storage.StartStream(clientId, evt)); + } +} diff --git a/src/Samples/Bobcat/BankAccountES/FreezeAccount.cs b/src/Samples/Bobcat/BankAccountES/FreezeAccount.cs new file mode 100644 index 000000000..03c67b23e --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/FreezeAccount.cs @@ -0,0 +1,59 @@ +using FluentValidation; +using JasperFx.Events; +using Wolverine.Persistence.EventSourcing; + +namespace BankAccountES; + +public record AccountFrozen(Guid AccountId, string Reason); + +/// +/// ⚠️ The PLANTED DISAGREEMENT for the four-source event-model vehicle (bobcat#172). The freeze +/// handler emits this event and the Bobcat spec deliberately does not mention it, so the +/// Declared claim on the FreezeAccount slice's emitted events ([AccountFrozen]) loses to the +/// Derived one ([AccountFrozen, AccountFlagged]) and the merge records a SourceDisagreement +/// hotspot (jasperfx#704) instead of silently swallowing the difference. Do not "fix" the spec +/// to mention this event — the gap is the point. +/// +public record AccountFlagged(Guid AccountId, string Reason); + +public record FreezeAccount(Guid AccountId, string Reason) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.AccountId).NotEmpty(); + RuleFor(x => x.Reason).NotEmpty(); + } + } +} + +/// +/// The one message-handled slice in the sample — the fraud desk freezes an account over the bus +/// rather than over HTTP, which is what lets the spec's WhenReceived(new FreezeAccount(…)) +/// hand it to the Wolverine bus. Same store-agnostic decider shape +/// as the HTTP endpoints: load the , return the events to append. +/// +/// +/// ⚠️ The RUNTIME plant for the vehicle's fourth rung (bobcat#172): the audit note is appended +/// IMPERATIVELY, through the session, inside the handler body — the documented case chain +/// derivation cannot see. Derived claims this slice emits [AccountFrozen, AccountFlagged]; +/// production appends those AND AuditTrailNoted. Under an observing CritterWatch console the +/// Observed rung wins the merge, and the dropped Derived claim becomes the SourceDisagreement +/// whose winner is finally the truth. Do not convert this to a declarative return — the +/// invisibility is the point. +/// +public record AuditTrailNoted(Guid AccountId, string Entry); + +public static class FreezeAccountHandler +{ + [DeciderFunction] + public static (AccountFrozen, AccountFlagged) Handle( + FreezeAccount command, Account account, IEventStoreOperations events) + { + events.Append(command.AccountId, new AuditTrailNoted(command.AccountId, $"Freeze requested: {command.Reason}")); + + return (new AccountFrozen(command.AccountId, command.Reason), + new AccountFlagged(command.AccountId, $"Frozen: {command.Reason}")); + } +} diff --git a/src/Samples/Bobcat/BankAccountES/OpenAccount.cs b/src/Samples/Bobcat/BankAccountES/OpenAccount.cs new file mode 100644 index 000000000..96ff9259a --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/OpenAccount.cs @@ -0,0 +1,52 @@ +using FluentValidation; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; +using Wolverine.Persistence; +using Wolverine.Persistence.EventSourcing; + +namespace BankAccountES; + +public record OpenAccount(Guid ClientId, string Currency = "USD") +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.ClientId).NotEmpty(); + RuleFor(x => x.Currency).NotEmpty().Length(3); + } + } +} + +public static class OpenAccountEndpoint +{ + // The [Entity] load is the interesting half: opening an account for a client that was never + // enrolled is a 400 from the middleware, before this method runs at all. [Entity] is Wolverine's + // store-agnostic document load, so it reads the Client snapshot from whichever store Program.cs + // registered. + // + // The write is a Storage.StartStream() side effect rather than a call on an injected session: + // the endpoint stays a pure function of its inputs, and Wolverine appends the stream through the + // registered event store — Marten or Fisher — inside the same transaction as the response. + // + // [Emits] is the Event Model's view of that side effect (wolverine GH-4386): the event rides + // inside the StartStream object, constructed here in the body, so nothing on the signature + // says what this slice appends. Without it no source claims OpenAccount emits AccountOpened, + // the store's Account view has no link back to the slice that starts every stream it folds, + // and the EventModel specs say so (bobcat#300). + [WolverinePost("/api/accounts")] + [Emits(typeof(AccountOpened))] + public static (Created, StartStream) Post( + OpenAccount command, + [Entity("ClientId", Required = true, OnMissing = OnMissing.ProblemDetailsWith400)] Client client) + { + var accountId = Guid.NewGuid(); + var evt = new AccountOpened(accountId, command.ClientId, command.Currency); + + var account = new Account(); + account.Apply(evt); + return ( + TypedResults.Created($"/api/accounts/{accountId}", account), + Storage.StartStream(accountId, evt)); + } +} diff --git a/src/Samples/Bobcat/BankAccountES/Program.cs b/src/Samples/Bobcat/BankAccountES/Program.cs new file mode 100644 index 000000000..f54d29984 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Program.cs @@ -0,0 +1,147 @@ +using BankAccountES; +using Fisher; +using JasperFx; +using Wolverine.CritterWatch; +using Wolverine.RabbitMQ; +using JasperFx.Events.EventModeling; +using JasperFx.Events.Projections; +using Marten; +using Wolverine; +using Wolverine.Fisher; +using Wolverine.FluentValidation; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +// The event store is a deployment choice, not a domain one. Every handler, projection and read +// endpoint in this sample is written against the store-agnostic vocabulary — Wolverine's +// [DeciderFunction] / [Entity] / Storage.StartStream() and JasperFx.Events' IEventStoreOperations / +// IDocumentReadOperations — so the only file that names a store is this one. `EventStore=Fisher` in +// configuration (or the environment) runs the same application on an embedded SQLite file with no +// Postgres at all; the default is Marten. See README.md and CLAUDE.md, "Bobcat.CritterStack is +// store-agnostic" (issue #103). +var eventStore = builder.Configuration["EventStore"] ?? "Marten"; + +switch (eventStore.ToLowerInvariant()) +{ + case "marten": + builder.Services.AddMarten(opts => + { + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=bank_account;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "bank"; + + // Inline snapshots — aggregates and the transaction-history read model are always up to date. + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }) + .IntegrateWithWolverine() + .UseLightweightSessions(); + break; + + case "fisher": + builder.Services.AddFisher(opts => + { + // An embedded SQLite file. Relative paths resolve against the working directory, so the + // default lands next to the host's appsettings.json; a spec run points it somewhere else. + var connectionString = builder.Configuration.GetConnectionString("Fisher") + ?? "Data Source=bank_account.db"; + + opts.Connection(connectionString); + + // Identical registrations — Snapshot is the one shape of projection every store spells + // the same way, because a self-aggregating document needs no store-specific base class. + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }) + // Not optional on a fresh database. Fisher builds its schema lazily, and the first append + // on an empty file reaches AppendPlanner.ReadCurrentVersionAsync before anything has created + // fi_streams / fi_events — "no such table" on the first three scenarios, then a pass once + // something else has built the tables. Marten creates them on the way in; Fisher wants the + // schema applied at startup. See docs/sample-wiring.md footgun 13. + .ApplyAllDatabaseChangesOnStartup() + .IntegrateWithWolverine(); + break; + + default: + throw new InvalidOperationException( + $"Unknown EventStore '{eventStore}'. This sample runs on 'Marten' (Postgres, the default) or 'Fisher' (SQLite)."); +} + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); + opts.ServiceName = "BankAccount"; + + // bobcat#172, the fourth rung: with CritterWatch:Uri configured, this sample becomes a + // MONITORED service — its event-model manifest (chains + overlay) pushes to the console on + // its own hash-gated message, and every event append inside a handler or endpoint is + // observed and attributed. Unconfigured (specs, CI, a bare dotnet run), nothing changes. + if (builder.Configuration["CritterWatch:Uri"] is { Length: > 0 } critterWatchUri) + { + opts.UseRabbitMq(new Uri(builder.Configuration["CritterWatch:Broker"] ?? "amqp://localhost:5673")) + .AutoProvision(); + opts.AddCritterWatchMonitoring( + critterWatchUri: new Uri(critterWatchUri), + systemControlUri: new Uri(builder.Configuration["CritterWatch:ControlUri"] ?? "rabbitmq://queue/bank-account-control")); + } +}); + +// The Event Model OVERLAY (jasperfx#687, decision D5): naming, grouping and trigger labels only — +// never a factual role. Roles (commands, emitted events, aggregates, read models) are DERIVED from +// the Wolverine chains and DECLARED by the Bobcat specs; the overlay contributes exactly the things +// no code can express. The model name matches opts.ServiceName above because upstream, +// EventModelDiscovery.Assemble folds descriptors together by model name — a mismatch here and the +// overlay floats off as its own model instead of merging (bobcat#172). +builder.Services.AddEventModel("BankAccount", model => +{ + model.InDomain("Banking"); + + // Human trigger labels on every command slice. wolverine#4181 (fixed in 6.31.0: the + // HTTP-derived source stopped claiming TriggerLabel with the verb+route, which had made + // every label here lose the merge) — the verb+route stays available on TriggerOrigin, and + // these Declared labels now win because nothing else claims the role. + model.Slice("EnrollClient").TriggeredBy("New customer walks in"); + model.Slice("UpdateClient").TriggeredBy("Customer corrects their details"); + model.Slice("OpenAccount").TriggeredBy("Enrolled client asks for an account"); + model.Slice("DepositFunds").TriggeredBy("Customer at the teller"); + model.Slice("WithdrawFunds").TriggeredBy("Customer at the ATM"); + model.Slice("FreezeAccount").TriggeredBy("The fraud desk"); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +// The fraud desk's HTTP door: forwards straight to the FreezeAccount message handler, which +// stays the only decider. Wolverine folds this route and the handler into ONE event-model +// slice, because both key on the FreezeAccount type. +app.MapPostToWolverine("/api/accounts/freeze"); + +// RunJasperFxCommands rather than RunAsync so `dotnet run -- event-model --url …` works against +// this host — the export command builds the host without starting it and PUTs the assembled, +// provenance-stamped descriptor to a Bobcat console (wolverine#3990, bobcat#171). A bare +// `dotnet run` still runs the app exactly as before. +return await app.RunJasperFxCommands(args); diff --git a/src/Samples/Bobcat/BankAccountES/README.md b/src/Samples/Bobcat/BankAccountES/README.md new file mode 100644 index 000000000..719d5c4a3 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/README.md @@ -0,0 +1,155 @@ +# Bank Account (Event Sourcing) -- Critter Stack Sample + +## Domain Reference + +**Inspired by:** [andreschaffer/event-sourcing-cqrs-examples](https://github.com/andreschaffer/event-sourcing-cqrs-examples) (Java) + +A minimalistic bank domain demonstrating event sourcing with Wolverine's store-agnostic aggregate handlers. Not a code port -- built from scratch using the Java project's domain concepts. + +**It runs on two event stores from the same handlers.** `Program.cs` is the only file that names a store: `EventStore=Marten` (the default -- Postgres, `ConnectionStrings:Marten`) or `EventStore=Fisher` (an embedded SQLite file, `ConnectionStrings:Fisher`, no docker). Everything else is written against the vocabulary Wolverine and JasperFx.Events share across Marten, Polecat and Fisher -- `[DeciderFunction]`, `[Entity]`, `Storage.StartStream()`, `IEventStoreOperations`, `IDocumentReadOperations`, and `Snapshot` projections. See `docs/sample-wiring.md` footgun 14 for the swap table, and CLAUDE.md "Bobcat.CritterStack is store-agnostic" for why (issue #103). + +## Domain + +- **Client** -- enroll, update profile (event-sourced) +- **Account** -- open, deposit, withdraw (event-sourced, with balance guard) +- **Transaction History** -- inline projection built from deposit/withdrawal events + +## Patterns Demonstrated + +### Decider Function Workflow (`[DeciderFunction]`) + +Deposit and withdrawal operations use Wolverine's store-agnostic `[DeciderFunction]` attribute (`Wolverine.Persistence.EventSourcing`; `Wolverine.Marten`'s `[AggregateHandler]` derives from it). Wolverine loads the Account aggregate from the event stream of whichever store is registered, passes it to the handler, appends the returned event, and commits -- all in one transaction. + +```csharp +[WolverinePost("/api/accounts/{accountId}/deposits")] +[DeciderFunction] +public static (IResult, FundsDeposited) Post(DepositFunds command, Account account) +{ + var newBalance = account.Balance + command.Amount; + return (Results.NoContent(), new FundsDeposited(command.AccountId, command.Amount, newBalance)); +} +``` + +### Starting a stream without naming the store + +`EnrollClient` and `OpenAccount` return a `Storage.StartStream(id, events)` side effect beside their `Created` response. The endpoint stays a pure function; Wolverine appends through the registered event store in the same transaction. (`IEventStoreOperations` is the injectable alternative when a handler needs more than a start or an append.) + +### Validate Against Aggregate State + +Withdrawal validates against the loaded aggregate's balance using a separate `Validate` method (Railway Programming pattern): + +```csharp +public static ProblemDetails Validate(WithdrawFunds command, Account account) +{ + if (account.Balance < command.Amount) + return new ProblemDetails { Detail = "Insufficient funds", Status = 400 }; + return WolverineContinue.NoProblems; +} +``` + +### Inline Snapshot Projections + +Account, Client **and the `AccountTransactions` read model** use `SnapshotLifecycle.Inline` -- the snapshot document is always up-to-date after each event append. `Snapshot` is the one projection shape every store spells identically, which is why the transaction history is a self-aggregating document (`Create(AccountOpened)`, `Apply(IEvent)`, …) rather than a `SingleStreamProjection<,>` subclass -- that base class is Marten's, and Fisher and Polecat each have their own. + +### Store-agnostic reads + +The query endpoints take `IDocumentReadOperations` (documents: `LoadAsync`, `Query()` + `JasperFx.Events.Documents.ToListAsync`) and `IEventStoreOperations` (`AggregateStreamAsync`), which Wolverine resolves to the current session on Marten, Polecat and Fisher alike. + +### Entity Loading with Batch Query + +`OpenAccount` uses `[Entity]` to load the Client by `ClientId` -- verifying the client exists before opening an account. + +The spec arranges that Client with `GivenContext(…)`, not `GivenEvents(…)` (bobcat#502). At run time the two are the same append; on the Event Model, `GivenContext` puts Client in the OpenAccount slice's `ReadsFrom` -- a lookup the slice reads before deciding -- instead of claiming it as an aggregate. The stream OpenAccount decides against is the Account it starts. `EventModel.cs` pins it. + +## Running + +On Marten (the default) it needs the samples' Postgres -- `cd samples && docker compose up -d`. On Fisher it needs nothing. + +```bash +dotnet run # Marten +EventStore=Fisher dotnet run # Fisher, bank_account.db next to appsettings.json + +dotnet test --project Tests # the Bobcat specs, on Marten +EventStore=Fisher dotnet test --project Tests # the same specs, on Fisher +BOBCAT_SPEC_CONSOLE=1 dotnet run --project Tests # ...and print every spec as it rendered +``` + +**The specs run one at a time, on purpose** (`Tests/AssemblyAttributes.cs`). Every act here is Bobcat's store-only act, which reads back what the store gained while it ran and attributes it by stream (bobcat#503): events another test appends to its own streams are set aside as `LastAct.ForeignEvents`, and a failing `ThenEvents` says "cross-talk suspected" when there were any. That narrows the problem without closing it -- a stream another test *starts* at the same moment still looks like one this act started, and every spec here starts one (`EnrollClient`, `OpenAccount`). So whole-store assertions on a shared store keep parallelization off. For real parallelism, act through Wolverine's tracked session with WolverineFx.Bobcat's `WolverineScenario`, or give each test its own store or tenant. + +Fisher builds its schema lazily, so the host calls `ApplyAllDatabaseChangesOnStartup()` -- without it the first append on a fresh file fails with `no such table: fi_streams` (footgun 13). + +**Polecat (SQL Server) is the documented manual path, not a wired one.** It would be a third `case` in `Program.cs` of the same shape -- `AddPolecat(opts => { opts.Connection(...); opts.Projections.Snapshot<...>(SnapshotLifecycle.Inline); }).IntegrateWithWolverine()` from `WolverineFx.Polecat` -- and nothing outside `Program.cs` would change, because Polecat registers `IEventStore`, implements the same `IDocumentReadOperations` / `IEventStoreOperations` contracts, and `Wolverine.Polecat`'s `[AggregateHandler]` derives from the same `[DeciderFunction]`. It is not in this repo's `Program.cs` because nothing here has run it against a SQL Server, and a branch nobody has run is footgun 4 waiting to happen. + +Swagger UI at `/swagger`. + +## Specifications + + +The specs in `Tests/` are Bobcat's projected (code-first) model: ordinary xUnit v3 tests that render as +Given/When/Then specifications and land on the Event Model. There are no `.feature` files anymore. Every +spec class derives from `BankAccountSpec`, which is Bobcat's store-only `CritterStackSpec` plus the two ways +this application is driven -- an HTTP request through Alba, or a message handed to the Wolverine bus -- so +the same specs run on Marten and on Fisher. + +```csharp +[BobcatSpec(typeof(WithdrawFunds), Domain = "Banking")] +public async Task withdrawing_more_than_the_balance_leaves_the_account_untouched() +{ + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, + new AccountOpened(accountId, Guid.NewGuid(), "USD"), + new FundsDeposited(accountId, 100, 100)); + + await WhenPosted(new WithdrawFunds(accountId, 200), "/api/accounts/{accountId}/withdrawals", accountId); + + ThenTheResponseStatusIs(400); + ThenNoEvents(); + await ThenAggregate(accountId, Specify().With(x => x.Balance, 100m)); +} +``` + +Nothing in that test is written for the Event Model, and it still tells the model plenty. `[BobcatSpec]` +says which slice the test is evidence for. The roles come off the vocabulary the test calls, through +`[EventModelRole]`: `GivenEvents` makes `Account` the slice's aggregate, `WhenPosted` makes +`WithdrawFunds` its command (and, through `[EventModelTrigger]`, `POST /api/accounts/{accountId}/withdrawals` +its trigger), and `ThenEvents` in the happy-path spec names what it emits. `WhenPosted`, `WhenPut`, +`WhenReceived` and `WhenReading` are the sample's own methods in `BankAccountSpec.cs`, and they re-declare +those attributes, because the generator reads the method a test calls and never what that method calls. + +`WhenReading` declares a `GET` trigger beside a `ReadModel` role, which makes it a read act: a spec whose +act it is lands on a **Query** slice (bobcat#501). The slice reads `T` through `ReadsFrom`. Streams the spec +arranged with `GivenEvents` are setup feeding a projection, so they stamp nothing on the query. The +two GET specs bind the slices Wolverine derives from those endpoints, which it names for their routes. + +| Spec class | What it covers | +|------------|----------------| +| `BankAccountEventSourcing` | the command slices over HTTP, plus the transaction-history and client-accounts queries | +| `AccountView` | the `Account` View slice: arranged events, no act, the read model they fold into | +| `FreezeAccountSpecs` | the one message-handled slice, deliberately silent about `AccountFlagged` | +| `EventModel` | the assembled Event Model itself: four sources, one model, one planted disagreement | + +The `EventModel` specs are the interesting ones. They assemble the model from the Wolverine chains, the +store's projection registry, the overlay in `Program.cs` and this assembly's generated +`BobcatEventModelSource`, and assert how it folds -- which source won each role, that the store's `Account` +view and the spec's are one slice, and that the planted disagreement on `FreezeAccount` (bobcat#172) +surfaces as a hotspot instead of vanishing. Set `BOBCAT_EVENT_MODEL_EXPORT=` to write the merged +model out as JSON. + +The GET slices carry no disagreement: since [wolverine#4975](https://github.com/JasperFx/wolverine/issues/4975) +Wolverine derives a GET chain as a Query that reads its response type, which is what the specs say too. Before it, +both GET slices merged as a View with a Pattern `SourceDisagreement`, and the `EventModel` specs pinned that so the +fix would turn them red. It did when the sample moved into the Wolverine repository and ran against it. + +## API + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/clients` | Enroll a new client | +| PUT | `/api/clients/{clientId}` | Update client profile | +| GET | `/api/clients/{id}` | Get client | +| POST | `/api/accounts` | Open account for a client | +| GET | `/api/accounts/{id}` | Get account | +| GET | `/api/clients/{clientId}/accounts` | Get all accounts for a client | +| POST | `/api/accounts/{accountId}/deposits` | Deposit funds | +| POST | `/api/accounts/{accountId}/withdrawals` | Withdraw funds | +| GET | `/api/accounts/{accountId}/transactions` | Transaction history | diff --git a/src/Samples/Bobcat/BankAccountES/Tests/AccountView.cs b/src/Samples/Bobcat/BankAccountES/Tests/AccountView.cs new file mode 100644 index 000000000..725c8fd4b --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/AccountView.cs @@ -0,0 +1,52 @@ +using Bobcat.Xunit; + +namespace BankAccountES.Tests; + +/// +/// The Account read model is a View slice -- event, projection, document -- and the store derives one per +/// registered projection, named after the DOCUMENT type (jasperfx#825, registered by AddMarten / AddFisher). +/// These specs bind the same slice by the same name, [BobcatSpec(typeof(Account))], so the spec rung +/// and the store rung fold into ONE sticky. Nothing enforces that the two agree on the name; +/// is what +/// keeps it true (bobcat#300). +/// +/// +/// +/// There is deliberately no act: a View slice receives no command. Its arranged events are the events the +/// projection folds, which is why the generator stamps them as what the slice CONSUMES (bobcat#297). +/// +/// +/// The store says the Account projection applies FOUR events, and lists merge by equality, not union: a +/// spec-declared list that names fewer is a SourceDisagreement hotspot. So between them these two specs +/// arrange all four. Drop one and the Event Model's "no source disagreement" goes red, which is the right +/// reading: the view is under-specified. +/// +/// +[BobcatFeature("Account View")] +public class AccountView(BankAccountHost app) : BankAccountSpec(app) +{ + [BobcatSpec(typeof(Account), Domain = "Banking", Pattern = "View")] + public async Task the_account_read_model_folds_the_streams_history() + { + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, + new AccountOpened(accountId, Guid.NewGuid(), "EUR"), + new FundsDeposited(accountId, 250, 250)); + + await ThenReadModel(accountId, + Specify().With(x => x.Balance, 250m).With(x => x.Currency, "EUR").With(x => x.IsFrozen, false)); + } + + [BobcatSpec(typeof(Account), Domain = "Banking", Pattern = "View")] + public async Task the_account_read_model_folds_a_withdrawal_and_a_freeze() + { + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, + new AccountOpened(accountId, Guid.NewGuid(), "USD"), + new FundsDeposited(accountId, 100, 100), + new FundsWithdrawn(accountId, 40, 60), + new AccountFrozen(accountId, "Suspected fraud")); + + await ThenReadModel(accountId, Specify().With(x => x.Balance, 60m).With(x => x.IsFrozen, true)); + } +} diff --git a/src/Samples/Bobcat/BankAccountES/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/BankAccountES/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..307feb5c6 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/AssemblyAttributes.cs @@ -0,0 +1,35 @@ +using BankAccountES.Tests; +using Bobcat; +using Microsoft.AspNetCore.Mvc.Testing; + +// Names the Event Model this assembly's specs contribute slices to. Upstream merges descriptors +// BY MODEL NAME, and the Wolverine-derived model is named for the service ("BankAccount", +// Program.cs's opts.ServiceName) -- without this the generated BobcatEventModelSource would name +// its model "BankAccountES.Tests" and the spec slices could never fold into the same model as +// the chains they describe (bobcat#172). +[assembly: EventModelName("BankAccount")] + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(BankAccountHost))] + +// Not optional. CritterStackScenario's store-only act reads back everything appended to the store +// while it ran, by sequence, and attributes it by stream (bobcat#503): another test's appends to its +// own streams are set aside as ForeignEvents, but a stream another test STARTS at the same moment +// still looks like one this act started. Whole-store assertions on a store-only act need serial +// execution on a shared store (this), or a store or tenant per test. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tells ASP.NET Core's WebApplicationFactory (which Alba uses) where the host's content root +// is. Without it, discovery walks up from the test assembly's bin directory and synthesizes +// `samples/BankAccountES/BankAccountES/` from the host assembly name -- a folder that does not +// exist -- and throws DirectoryNotFoundException. The Tests project lives INSIDE the host +// project rather than beside it, which is not the layout the default assumes. +// +// The path is relative to the test assembly's bin directory at run time: from +// `samples/BankAccountES/Tests/bin//net10.0/`, four levels up is +// `samples/BankAccountES/`, where the host's appsettings.json lives. +[assembly: WebApplicationFactoryContentRoot( + "BankAccountES", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/BankAccountES/Tests/BankAccountEventSourcing.cs b/src/Samples/Bobcat/BankAccountES/Tests/BankAccountEventSourcing.cs new file mode 100644 index 000000000..4c494ebf5 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/BankAccountEventSourcing.cs @@ -0,0 +1,168 @@ +namespace BankAccountES.Tests; + +/// +/// The command slices, driven over HTTP the way a client would: each spec arranges the aggregate's +/// history as events, sends ONE request, and asserts what it appended and what the aggregate became. +/// +/// +/// +/// The slice binding. [BobcatSpec(typeof(X))] names the slice after the request type, which +/// is the same key the Wolverine.Http chain derives, so these specs land on the same slices as the derived +/// roles. The roles themselves -- command, aggregate, emitted events -- come off the vocabulary each spec +/// calls (bobcat#462). Domain is restated per spec because a method's binding replaces the class's. +/// +/// +/// The aggregate is read back, not echoed. ThenAggregate rebuilds the aggregate from its +/// stream; asserting against what the POST answered would test nothing. +/// +/// +[BobcatFeature("Bank Account Event Sourcing")] +public class BankAccountEventSourcing(BankAccountHost app) : BankAccountSpec(app) +{ + [BobcatSpec(typeof(EnrollClient), Domain = "Banking")] + public async Task enroll_a_client() + { + await WhenPosted(new EnrollClient("John Doe", "john@example.com"), "/api/clients"); + + ThenTheResponseStatusIs(201); + ThenEvents(Specify().With(x => x.Name, "John Doe").With(x => x.Email, "john@example.com")); + await ThenAggregate(TheStartedStream(), + Specify().With(x => x.Name, "John Doe").With(x => x.Email, "john@example.com")); + } + + [BobcatSpec(typeof(UpdateClient), Domain = "Banking")] + public async Task update_a_client() + { + var clientId = Guid.NewGuid(); + await GivenEvents(clientId, new ClientEnrolled(clientId, "Jane Doe", "jane@example.com")); + + // The command carries the email as well as the name, so the update replays the enrolled address + // By route name rather than URL (bobcat#500): the slice still reaches the model as PUT /api/clients/{clientId} + await WhenPutTo(new UpdateClient(clientId, "Jane Smith", "jane@example.com"), "UpdateClient", clientId); + + ThenTheResponseStatusIs(204); + ThenEvents(new ClientUpdated(clientId, "Jane Smith", "jane@example.com")); + await ThenAggregate(clientId, + Specify().With(x => x.Name, "Jane Smith").With(x => x.Email, "jane@example.com")); + } + + [BobcatSpec(typeof(OpenAccount), Domain = "Banking")] + public async Task open_a_bank_account() + { + var clientId = Guid.NewGuid(); + // A lookup, not the stream the command decides against: the endpoint's [Entity] loads the client + await GivenContext(clientId, new ClientEnrolled(clientId, "Alice", "alice@example.com")); + + await WhenPosted(new OpenAccount(clientId), "/api/accounts"); + + ThenTheResponseStatusIs(201); + ThenEvents(Specify().With(x => x.ClientId, clientId).With(x => x.Currency, "USD")); + await ThenAggregate(TheStartedStream(), Specify().With(x => x.Balance, 0m)); + } + + [BobcatSpec(typeof(OpenAccount), Domain = "Banking")] + public async Task open_an_account_for_a_client_who_was_never_enrolled() + { + // [Entity(Required = true)] answers 400 before the endpoint runs at all + await WhenPosted(new OpenAccount(Guid.Parse("11111111-1111-1111-1111-111111111111")), "/api/accounts"); + + ThenTheResponseStatusIs(400); + ThenNoEvents(); + } + + [BobcatSpec(typeof(DepositFunds), Domain = "Banking")] + public async Task deposit_funds() + { + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, new AccountOpened(accountId, Guid.NewGuid(), "USD")); + + await WhenPosted(new DepositFunds(accountId, 100), "/api/accounts/{accountId}/deposits", accountId); + + ThenTheResponseStatusIs(204); + ThenEvents(new FundsDeposited(accountId, 100, 100)); + await ThenAggregate(accountId, Specify().With(x => x.Balance, 100m)); + } + + [BobcatSpec(typeof(WithdrawFunds), Domain = "Banking")] + public async Task withdraw_funds() + { + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, + new AccountOpened(accountId, Guid.NewGuid(), "USD"), + new FundsDeposited(accountId, 200, 200)); + + await WhenPosted(new WithdrawFunds(accountId, 50), "/api/accounts/{accountId}/withdrawals", accountId); + + ThenTheResponseStatusIs(204); + ThenEvents(new FundsWithdrawn(accountId, 50, 150)); + await ThenAggregate(accountId, Specify().With(x => x.Balance, 150m)); + } + + // #region overdraft-http + [BobcatSpec(typeof(WithdrawFunds), Domain = "Banking")] + public async Task withdrawing_more_than_the_balance_leaves_the_account_untouched() + { + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, + new AccountOpened(accountId, Guid.NewGuid(), "USD"), + new FundsDeposited(accountId, 100, 100)); + + await WhenPosted(new WithdrawFunds(accountId, 200), "/api/accounts/{accountId}/withdrawals", accountId); + + ThenTheResponseStatusIs(400); + ThenNoEvents(); + await ThenAggregate(accountId, Specify().With(x => x.Balance, 100m)); + } + // #endregion overdraft-http + + /// + /// A query slice (bobcat#501): the read goes through the GET endpoint, so the spec lands on the slice Wolverine + /// derives from it, named for its route. It READS -- the store's View slice of + /// that name produces it, and the two are joined by a ReadModelRead link. The arranged Account stream is + /// setup feeding that projection, so it stamps nothing on the query. + /// + [BobcatSpec(SliceName = "GET /api/accounts/{accountId}/transactions", Domain = "Banking")] + public async Task transaction_history_records_every_deposit_and_withdrawal() + { + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, + new AccountOpened(accountId, Guid.NewGuid(), "USD"), + new FundsDeposited(accountId, 100, 100), + new FundsWithdrawn(accountId, 30, 70)); + + await WhenReading("/api/accounts/{accountId}/transactions", accountId); + + var history = await TheResponse(); + ThereAreTransactions(history, 2); + TheTransactionHistoryBalanceIs(history, 70); + } + + /// + /// A query slice: Wolverine derives it from the GET endpoint and names it for its route, because a GET + /// has no request type to key on. The arranged AccountOpened is setup (bobcat#501), so the slice + /// claims neither to consume it nor to read its stream. + /// + [BobcatSpec(SliceName = "GET /api/clients/{clientId}/accounts", Domain = "Banking")] + public async Task get_client_accounts() + { + var clientId = Guid.NewGuid(); + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, new AccountOpened(accountId, clientId, "USD")); + + await WhenReading("/api/clients/{clientId}/accounts", clientId); + + ThereAreAccounts(await TheResponse>(), 1); + } + + [Then("there are {count} transactions")] + public void ThereAreTransactions(AccountTransactions history, int count) + => SpecAssert.Check("count", history.Transactions.Count, count); + + [Then("the transaction history balance is {balance}")] + public void TheTransactionHistoryBalanceIs(AccountTransactions history, decimal balance) + => SpecAssert.Check("balance", history.Balance, balance); + + [Then("there is {count} account")] + public void ThereAreAccounts(List accounts, int count) + => SpecAssert.Check("count", accounts.Count, count); +} diff --git a/src/Samples/Bobcat/BankAccountES/Tests/BankAccountHost.cs b/src/Samples/Bobcat/BankAccountES/Tests/BankAccountHost.cs new file mode 100644 index 000000000..d6a52d4ef --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/BankAccountHost.cs @@ -0,0 +1,32 @@ +using Alba; +using Bobcat.CritterStack; + +namespace BankAccountES.Tests; + +/// +/// The application under test, booted once for the run through Alba. Configuration comes from the +/// host's own appsettings.json and the environment, so EventStore=Fisher or +/// ConnectionStrings__Marten=... point the same specs at another store or database. +/// +// #region app-host +public class BankAccountHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + Host = await AlbaHost.For(); + + // Not load-bearing for correctness: every spec mints its own ids, so no run can collide + // with the last one. It is here so the event store does not grow without bound across runs. + // ResetEventStoresAsync empties every store the host registers -- documents AND streams -- + // through JasperFx.Events.IEventStore, so it reads the same on Marten and Fisher. + await Host.ResetEventStoresAsync(); + } + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} +// #endregion app-host diff --git a/src/Samples/Bobcat/BankAccountES/Tests/BankAccountSpec.cs b/src/Samples/Bobcat/BankAccountES/Tests/BankAccountSpec.cs new file mode 100644 index 000000000..5fd6b2388 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/BankAccountSpec.cs @@ -0,0 +1,155 @@ +using System.Text.RegularExpressions; +using Alba; +using Bobcat; +using Bobcat.CritterStack; +using JasperFx.Events.EventModeling; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Wolverine; + +namespace BankAccountES.Tests; + +/// +/// The base for every BankAccountES spec: Bobcat's store-only vocabulary +/// (arrange events, assert events, aggregates and read models), plus the two ways this application is +/// driven -- over HTTP and over the Wolverine bus. +/// +/// +/// +/// Why the acts live here. is deliberately store-only, so it has +/// no HTTP or message act of its own. Each act below runs through , +/// which takes the store's high-water mark, runs the act, and reads back everything appended since -- so +/// ThenEvents and ThenNoEvents judge exactly what the request appended. +/// +/// +/// Why they re-declare the attributes. The generator reads and +/// off the method a test calls, never off what that method's body +/// calls. These attributes are what make the last command a test sends its slice's command, and its route +/// the slice's trigger. +/// +/// +public abstract class BankAccountSpec(BankAccountHost app) : CritterStackSpec(app.Host) +{ + private int _lastStatusCode; + private IScenarioResult? _lastResponse; + + protected IAlbaHost Alba => app.Host; + + // ---- acts ------------------------------------------------------------------------------ + + /// + /// POST to . Write the route as the endpoint's + /// template -- "/api/accounts/{accountId}/deposits" -- with its values after it, in order: a constant + /// template is what reaches the Event Model as the slice's route. + /// + // #region when-posted + [EventModelRole(SliceRole.Command, nameof(command))] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "POST")] + public Task WhenPosted(object command, string route, params object[] routeValues) + => send("POST", command, route, routeValues); + // #endregion when-posted + + /// PUT to , written as for . + [EventModelRole(SliceRole.Command, nameof(command))] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "PUT")] + public Task WhenPut(object command, string route, params object[] routeValues) + => send("PUT", command, route, routeValues); + + /// + /// PUT to the endpoint whose ASP.NET Core route name is , + /// with the template's values after it, in order (bobcat#500). The generator resolves a constant route name to + /// the endpoint's template at compile time, so the slice gets the same route a template would have given it. + /// + // #region when-put-to + [EventModelRole(SliceRole.Command, nameof(command))] + [EventModelTrigger(ActTrigger.Http, RouteName = nameof(routeName), HttpMethod = "PUT")] + public Task WhenPutTo(object command, string routeName, params object[] routeValues) + => send("PUT", command, templateNamed(routeName), routeValues); + // #endregion when-put-to + + /// Hand to its Wolverine handler over the bus, and wait for it to finish. + // #region when-received + [EventModelRole(SliceRole.Command, nameof(message))] + [EventModelTrigger(ActTrigger.MessageHandler)] + public Task WhenReceived(object message) + => WhenActing($"{message.GetType().Name} is received", + services => services.GetRequiredService().InvokeAsync(message)); + // #endregion when-received + + /// + /// GET (a template, as for ) and keep the response, which + /// is read as a -- the read model a query slice reads. + /// + /// + /// A GET trigger beside a read role and no command makes this a read act (bobcat#501): a spec whose last + /// act it is describes a Query slice, which reads through ReadsFrom rather than + /// producing it. Streams the spec arranged with GivenEvents are setup and stamp nothing. + /// + // #region when-reading + [EventModelRole(SliceRole.ReadModel, nameof(T))] + [EventModelTrigger(ActTrigger.Http, Route = nameof(route), HttpMethod = "GET")] + public Task WhenReading(string route, params object[] routeValues) where T : class + => send("GET", null, route, routeValues); + // #endregion when-reading + + private Task send(string verb, object? command, string route, object[] routeValues) + { + var url = fill(route, routeValues); + + // The step reads as the route template, which says which endpoint; the filled URL would only add a GUID + var description = command is null ? $"{verb} {route} is read" : $"{command.GetType().Name} is sent as {verb} {route}"; + + return WhenActing(description, async _ => + { + _lastResponse = await Alba.Scenario(x => + { + switch (verb) + { + case "POST": x.Post.Json(command!).ToUrl(url); break; + case "PUT": x.Put.Json(command!).ToUrl(url); break; + default: x.Get.Url(url); break; + } + + // A refusal is an answer to assert on, not an exception + x.IgnoreStatusCode(); + }); + + _lastStatusCode = _lastResponse.Context.Response.StatusCode; + }); + } + + // The running host's answer to a route name: the template of the endpoint carrying that RouteNameMetadata + private string templateNamed(string routeName) + { + var templates = Alba.Services.GetServices() + .SelectMany(x => x.Endpoints) + .OfType() + .Where(x => x.Metadata.GetMetadata()?.RouteName == routeName) + .Select(x => x.RoutePattern.RawText) + .Distinct() + .ToList(); + + return templates.Count == 1 + ? templates[0]! + : throw new ArgumentException($"Expected one endpoint with the route name '{routeName}', found {templates.Count}."); + } + + // "/api/accounts/{accountId}/deposits" + [id] => "/api/accounts//deposits" + private static string fill(string route, object[] values) + { + var i = 0; + var url = Regex.Replace(route, @"\{[^}]+\}", m => i < values.Length ? Uri.EscapeDataString(values[i++].ToString()!) : m.Value); + if (i != values.Length) + throw new ArgumentException($"Route '{route}' has {i} placeholder(s) but was given {values.Length} value(s)."); + return url; + } + + // ---- assertions on the response --------------------------------------------------------- + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + /// The last response's body, read as JSON. Not a step: the assertion that follows it is. + public Task TheResponse() + => (_lastResponse ?? throw new InvalidOperationException("No request has been sent yet.")).ReadAsJsonAsync()!; +} diff --git a/src/Samples/Bobcat/BankAccountES/Tests/EventModel.cs b/src/Samples/Bobcat/BankAccountES/Tests/EventModel.cs new file mode 100644 index 000000000..5b308e649 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/EventModel.cs @@ -0,0 +1,203 @@ +using Bobcat.Xunit; +using JasperFx.Events.EventModeling; + +namespace BankAccountES.Tests; + +/// +/// bobcat#172, the Bobcat-side half of the multi-source event-model vehicle. Four producers feed one +/// descriptor here -- this assembly's projected specs (Declared), the C# overlay in Program.cs (Declared), +/// the host's Wolverine + HTTP chains (Derived), and since bobcat#300 the host's STORE (Derived): one View +/// slice per registered projection, read from the projection registry by jasperfx#825 and registered by +/// AddMarten / AddFisher with no wiring here. These specs assert the fold: every role names its source, +/// declarations survive where nothing outranks them, and the deliberately planted disagreement +/// (FreezeAccount.cs emits AccountFlagged, which no spec mentions) surfaces as a hotspot instead of +/// vanishing. The fifth source -- runtime observation -- is CritterWatch's. +/// +/// +/// These specs bind no slice and call nothing that carries an Event Model role, so they contribute nothing +/// to the model they assert on. +/// +[BobcatFeature("Event Model")] +public class EventModel(BankAccountHost app) +{ + private readonly EventModelSteps _model = new(app); + + [BobcatSpec] + public async Task four_sources_fold_into_one_provenance_stamped_model() + { + await _model.AssembleEventModel(); + + _model.ThereIsExactlyOneModelNamed("BankAccount"); + _model.EveryClaimedRoleNamesItsSource(); + _model.TheRoleIsClaimedBy("FreezeAccount", EventModelRole.EmittedEvents, EventModelProvenance.Derived); + _model.TheRoleIsClaimedBy("FreezeAccount", EventModelRole.HandlerType, EventModelProvenance.Derived); + _model.TheRoleIsClaimedBy("FreezeAccount", EventModelRole.Domain, EventModelProvenance.Declared); + _model.TheRoleIsClaimedBy("FreezeAccount", EventModelRole.TriggerLabel, EventModelProvenance.Declared); + _model.TheRoleIsClaimedBy("FreezeAccount", EventModelRole.Specifications, EventModelProvenance.Declared); + } + + [BobcatSpec] + public async Task the_planted_disagreement_surfaces_as_a_hotspot_instead_of_vanishing() + { + await _model.AssembleEventModel(); + + _model.TheSliceReportsADisagreementOn("FreezeAccount", EventModelRole.EmittedEvents); + _model.ThatDisagreementKept(EventModelProvenance.Derived, "AccountFlagged"); + _model.ThatDisagreementDropped(EventModelProvenance.Declared, "AccountFrozen"); + _model.TheSliceReportsNoDisagreement("DepositFunds"); + _model.TheSliceReportsNoDisagreement("WithdrawFunds"); + + // bobcat#500: update_a_client addresses its endpoint by route name; the route the generator resolved + // from it is the specs' own claim, and agrees with the one Wolverine derives from the chain + _model.TheSpecsAloneSayTheSliceIsTriggeredAt("UpdateClient", "PUT /api/clients/{clientId}"); + _model.TheSliceReportsNoDisagreement("UpdateClient"); + } + + [BobcatSpec] + public async Task declared_names_domains_and_spec_bindings_survive_the_merge() + { + await _model.AssembleEventModel(); + + _model.TheSliceIsInDomain("FreezeAccount", "Banking"); + _model.TheSliceIsTriggeredBy("FreezeAccount", "The fraud desk"); + _model.TheSliceBindsTheSpecification("FreezeAccount", "Freeze Account/freezing an account records the freeze"); + _model.TheSliceBindsTheSpecification("DepositFunds", "Bank Account Event Sourcing/deposit funds"); + _model.TheSliceBindsTheSpecification("WithdrawFunds", + "Bank Account Event Sourcing/withdrawing more than the balance leaves the account untouched"); + } + + /// + /// wolverine#4181, FIXED at 6.31.0 -- the flipped tripwire: the HTTP-derived source no longer claims + /// TriggerLabel, so the overlay's human label wins the role by being its only claimant. + /// + [BobcatSpec] + public async Task an_http_slices_trigger_label_belongs_to_the_overlay_again() + { + await _model.AssembleEventModel(); + + _model.TheRoleIsClaimedBy("WithdrawFunds", EventModelRole.TriggerLabel, EventModelProvenance.Declared); + _model.TheSliceIsTriggeredBy("WithdrawFunds", "Customer at the ATM"); + } + + /// + /// bobcat#175: read models asserted as an identity, not only as a provenance claim -- which is how + /// wolverine#4182 came to be caught by eye on the canvas rather than by a spec. + /// + /// + /// These are Wolverine's claims. Since wolverine#4975 it derives a GET chain as a Query that READS what it returns, + /// so the type is in ReadsFrom, as the specs already said it was + /// (). Before it, a GET chain was a View that + /// produced its response type as a read model, and these steps checked ReadModelTypes. + /// + [BobcatSpec] + public async Task a_query_slice_reads_the_document_type_it_returns() + { + await _model.AssembleEventModel(); + + _model.TheSliceReadsFrom("GET /api/accounts/{id}", "Account"); + _model.TheSliceReadsFrom("GET /api/clients/{id}", "Client"); + _model.TheSliceReadsFrom("GET /api/accounts/{accountId}/transactions", "AccountTransactions"); + } + + /// + /// bobcat#501: a GET spec is a Query slice. Its read type goes to ReadsFrom, so the store's AccountTransactions + /// View is joined to the transactions query by a ReadModelRead link nobody drew. The Account stream the spec + /// arranges with GivenEvents is setup feeding that projection, so it stamps nothing: no ReadsFrom (which used to + /// link every slice producing Account to the query), no ConsumedEvents. + /// + /// + /// Until wolverine#4975 Wolverine derived every GET chain as a View, and Derived outranks Declared, so the merged + /// pattern was View with one Query-vs-View Pattern disagreement per GET slice, pinned here so that the fix would + /// turn it red. It did when these samples moved into the Wolverine repository (GH-5082) and ran against the + /// Wolverine that has the fix, so the steps now say what was always meant: a Query, and no disagreement. + /// + [BobcatSpec] + public async Task a_get_spec_is_a_query_slice_that_reads_what_it_returns() + { + _model.TheSpecsAloneSayTheSliceIsAQueryReading("GET /api/accounts/{accountId}/transactions", "AccountTransactions"); + _model.TheSpecsAloneSayTheSliceIsAQueryReading("GET /api/clients/{clientId}/accounts", "Account"); + + await _model.AssembleEventModel(); + _model.TheSliceReadsFromExactly("GET /api/accounts/{accountId}/transactions", "AccountTransactions"); + _model.TheSliceReadsFromExactly("GET /api/clients/{clientId}/accounts", "Account"); + _model.ThereIsALink(EventModelLinkKind.ReadModelRead, "AccountTransactions", + "GET /api/accounts/{accountId}/transactions", "AccountTransactions"); + _model.ThereIsNoLinkTo(EventModelLinkKind.ReadModelRead, "GET /api/accounts/{accountId}/transactions", "Account"); + + // wolverine#4975: Wolverine derives the GET chain as a Query too, so the sources agree + _model.TheGetSliceIsAQueryTheSourcesAgreeOn("GET /api/accounts/{accountId}/transactions"); + _model.TheGetSliceIsAQueryTheSourcesAgreeOn("GET /api/clients/{clientId}/accounts"); + } + + /// + /// wolverine#4182, FIXED at 6.31.0 (via #4185): the collection query unwraps to its element type and + /// shares the Account node with its single-document sibling instead of minting a raw-generic one. + /// + [BobcatSpec] + public async Task a_collection_query_reads_the_element_type_it_returns() + { + await _model.AssembleEventModel(); + + _model.TheSliceReadsFrom("GET /api/clients/{clientId}/accounts", "Account"); + } + + /// + /// bobcat#300 -- the store rung. Slices merge BY NAME, and the store names its View slice after the + /// document type, which is how already binds it. That agreement is entirely + /// conventional; this spec is what enforces it. + /// + [BobcatSpec] + public async Task the_store_rung_and_the_spec_rung_describe_one_account_slice_not_two() + { + await _model.AssembleEventModel(); + + _model.ThereIsExactlyOneSliceNamed("Account"); + _model.TheSliceHasPattern("Account", SlicePattern.View); + _model.TheSliceReadsTheReadModel("Account", "Account"); + _model.TheRoleIsClaimedBy("Account", EventModelRole.ReadModelTypes, EventModelProvenance.Derived); + _model.TheRoleIsClaimedBy("Account", EventModelRole.ConsumedEvents, EventModelProvenance.Derived); + _model.TheSliceConsumes("Account", "AccountOpened"); + _model.TheSliceConsumes("Account", "FundsDeposited"); + _model.TheSliceConsumes("Account", "FundsWithdrawn"); + _model.TheSliceConsumes("Account", "AccountFrozen"); + _model.TheSpecsAloneSayTheSliceConsumes("Account", "AccountOpened"); + _model.TheSpecsAloneSayTheSliceConsumes("Account", "FundsDeposited"); + _model.TheSpecsAloneSayTheSliceConsumes("Account", "FundsWithdrawn"); + _model.TheSpecsAloneSayTheSliceConsumes("Account", "AccountFrozen"); + _model.TheRoleIsClaimedBy("Account", EventModelRole.Specifications, EventModelProvenance.Declared); + _model.TheRoleIsClaimedBy("Account", EventModelRole.Domain, EventModelProvenance.Declared); + _model.TheSliceBindsTheSpecification("Account", "Account View/the account read model folds the streams history"); + _model.TheSliceReportsNoDisagreement("Account"); + } + + /// + /// bobcat#502: OpenAccount's spec arranges a Client stream only so the endpoint's [Entity] lookup finds + /// the client. GivenContext<Client> says so, and the slice reads Client instead of claiming it as + /// an aggregate -- the stream the command decides against is the Account it starts. + /// + [BobcatSpec] + public async Task an_arranged_lookup_is_read_from_and_not_an_aggregate_of_the_slice() + { + _model.TheSpecsAloneSayTheSliceReadsFrom("OpenAccount", "Client"); + + // ...and the merged model agrees: the chain's [Entity] Client is a read too (wolverine GH-4419) + await _model.AssembleEventModel(); + _model.TheSliceReadsFrom("OpenAccount", "Client"); + _model.IsNotAnAggregateOf("Client", "OpenAccount"); + } + + /// + /// The payoff of jasperfx#823 + #824 + #825 landing together: the store supplies ConsumedEvents, the roles + /// give them an element, and Links turns the pair into a cross-slice edge nobody drew. + /// + [BobcatSpec] + public async Task the_events_the_command_slices_emit_reach_the_account_view_as_links_nobody_drew() + { + await _model.AssembleEventModel(); + + _model.ThereIsALink(EventModelLinkKind.EventConsumed, "DepositFunds", "Account", "FundsDeposited"); + _model.ThereIsALink(EventModelLinkKind.EventConsumed, "WithdrawFunds", "Account", "FundsWithdrawn"); + _model.ThereIsALink(EventModelLinkKind.EventConsumed, "FreezeAccount", "Account", "AccountFrozen"); + _model.ThereIsALink(EventModelLinkKind.EventConsumed, "OpenAccount", "Account", "AccountOpened"); + } +} diff --git a/src/Samples/Bobcat/BankAccountES/Tests/EventModelSteps.cs b/src/Samples/Bobcat/BankAccountES/Tests/EventModelSteps.cs new file mode 100644 index 000000000..2776edc64 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/EventModelSteps.cs @@ -0,0 +1,375 @@ +using Bobcat; +using Bobcat.Generated.EventModel; +using JasperFx.Events.EventModeling; + +namespace BankAccountES.Tests; + +/// +/// The Bobcat-side half of the multi-source event-model vehicle (bobcat#172). Assembles one +/// provenance-stamped from the four design-time sources -- +/// the running host's Wolverine/HTTP chains (Derived), the store's projection registry (Derived, +/// jasperfx#825 / bobcat#300), the C# overlay in Program.cs (Declared), and this assembly's +/// generated BobcatEventModelSource (Declared) -- exactly the way +/// EventModelDiscovery would, so can assert that the merge +/// attributes every role, keeps declarations where nothing outranks them, and surfaces the +/// planted disagreement (FreezeAccount.cs) as a hotspot instead of swallowing it. +/// +/// +/// +/// The generated source is internal to this assembly and the host cannot reference its own +/// spec project, so this class -- running where both the booted host's container and the +/// generated source are reachable -- is currently the only place all the sources can compose. +/// +/// +/// Every type name here is a string, never a type argument. These steps assert on the model, so +/// they carry no : contributing to the model they read would make +/// the vehicle observe itself. Every step reports through , so a spec shows every +/// disagreement it reaches rather than only its first. +/// +/// +public class EventModelSteps(BankAccountHost app) +{ + private IReadOnlyList _models = []; + private HotspotDescriptor? _disagreement; + + private EventModelDescriptor? model() + { + if (_models.Count == 1) return _models[0]; + SpecAssert.Fail($"Expected exactly one assembled model, but got [{string.Join(", ", _models.Select(m => m.Name))}] -- assemble first, and check every source names the same model."); + return null; + } + + private EventModelSliceDescriptor? sliceNamed(string name) + { + if (model() is not { } m) return null; + var slice = m.Slices.FirstOrDefault(s => s.Name == name); + if (slice is null) SpecAssert.Fail($"No slice named '{name}'. Slices: {string.Join(", ", m.Slices.Select(s => s.Name))}"); + return slice; + } + + [When("the event model is assembled from the chains, the overlay, the store and this assembly's specs")] + public async Task AssembleEventModel() + { + var services = app.Host.Services; + + // What EventModelDiscovery.AssembleAsync(services) would do, plus this assembly's + // generated source -- which the host's container cannot see (see the class remarks). + // DiscoverAsync finds every IEventModelDefinitionSource the host registered: Wolverine's + // chains, the overlay, and -- since JasperFx.Events 2.69 (jasperfx#825, bobcat#300) -- the + // store's projection registry, which AddMarten / AddFisher register unasked. + var discovered = (await EventModelDiscovery.DiscoverAsync(services, CancellationToken.None)).ToList(); + + // Provenance is a default interface member, so it is only reachable through the interface. + IEventModelDefinitionSource specSource = BobcatEventModelSource.Instance; + var specs = await specSource.TryCreateAsync(services, CancellationToken.None); + if (specs is not null) discovered.Add(specs.WithProvenance(specSource.Provenance)); + + _models = EventModelDiscovery.Assemble(discovered); + + // Vehicle plumbing: with BOBCAT_EVENT_MODEL_EXPORT set to a file path, write the composed + // model as the same camelCase/PascalCase-enum JSON the console's PUT /api/event-model + // accepts -- this process is the only one that can see every source (see the class + // remarks), so this file is how the merged picture reaches a viewer today. + if (Environment.GetEnvironmentVariable("BOBCAT_EVENT_MODEL_EXPORT") is { Length: > 0 } path && _models.Count == 1) + { + var wire = new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + wire.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(_models[0], wire)); + } + } + + [Then("there is exactly one model, named {name}")] + public void ThereIsExactlyOneModelNamed(string name) + => SpecAssert.Fact(_models.Count == 1 && _models[0].Name == name, + $"Expected one model named '{name}', but got: [{string.Join(", ", _models.Select(m => m.Name))}]"); + + [Then("the {slice} slice's {role} role is claimed by {provenance}")] + public void TheRoleIsClaimedBy(string slice, EventModelRole role, EventModelProvenance provenance) + { + if (sliceNamed(slice) is not { } s) return; + var actual = s.ProvenanceFor(role); + SpecAssert.Fact(actual == provenance, $"{slice}.{role} is claimed by {actual?.ToString() ?? "nobody"}, expected {provenance}."); + } + + [Then("every claimed role on every slice names its source")] + public void EveryClaimedRoleNamesItsSource() + { + if (model() is not { } m) return; + var unattributed = (from slice in m.Slices + from role in Enum.GetValues() + where slice.Claims(role) && slice.ProvenanceFor(role) is null + select $"{slice.Name}.{role}").ToList(); + + SpecAssert.Fact(unattributed.Count == 0, $"Roles with no source: {string.Join(", ", unattributed)}"); + } + + /// + /// The vehicle plants exactly one disagreement (FreezeAccount.cs), so "the" disagreement is + /// well-defined; a second one appearing anywhere in the model is itself a finding this fails on. (The + /// Query-vs-View pattern disagreement every GET slice carried until wolverine#4975 used to be excepted here.) + /// + [Then("the {slice} slice reports a source disagreement on {role}")] + public void TheSliceReportsADisagreementOn(string slice, EventModelRole role) + { + if (sliceNamed(slice) is not { } s || model() is not { } m) return; + + var onRole = s.Hotspots.Where(h => h.Origin == HotspotOrigin.SourceDisagreement && h.Role == role).ToList(); + if (!SpecAssert.Fact(onRole.Count > 0, $"No SourceDisagreement hotspot on {slice}.{role}. Hotspots: {describeHotspots(s)}")) return; + + var all = m.Slices.SelectMany(x => x.Hotspots.Where(h => h.Origin == HotspotOrigin.SourceDisagreement)).ToList(); + if (SpecAssert.Fact(all.Count == 1, + $"Expected exactly one SourceDisagreement in the whole model, found {all.Count}: {string.Join(" | ", all.Select(h => h.Text))}")) + { + _disagreement = all[0]; + } + } + + [Then("that disagreement kept the {provenance} claim naming {value}")] + public void ThatDisagreementKept(EventModelProvenance provenance, string value) + { + var kept = _disagreement?.WinningClaim; + SpecAssert.Fact(kept is not null && kept.Provenance == provenance && kept.Value.Contains(value), + $"Winning claim is {kept?.Provenance} '{kept?.Value}', expected {provenance} naming '{value}'."); + } + + [Then("that disagreement dropped the {provenance} claim {value}")] + public void ThatDisagreementDropped(EventModelProvenance provenance, string value) + { + var lost = _disagreement?.LosingClaim; + SpecAssert.Fact(lost is not null && lost.Provenance == provenance && lost.Value == value, + $"Losing claim is {lost?.Provenance} '{lost?.Value}', expected {provenance} '{value}'."); + } + + /// + /// bobcat#501 and wolverine#4975: the specs say a GET slice is a Query, and Wolverine derives the GET chain as a + /// Query too, so the merged slice is a Query with no Pattern disagreement. Until wolverine#4975 the derived View + /// outranked the specs' Query and this step pinned that disagreement instead. + /// + [Then("the {slice} slice is a Query, and the sources agree on its pattern")] + public void TheGetSliceIsAQueryTheSourcesAgreeOn(string slice) + { + if (sliceNamed(slice) is not { } s) return; + + SpecAssert.Fact(s.Pattern == SlicePattern.Query, $"{slice}.Pattern is {s.Pattern?.ToString() ?? "null"}, expected Query."); + var onPattern = s.Hotspots.Where(h => h.Origin == HotspotOrigin.SourceDisagreement && h.Role == EventModelRole.Pattern).ToList(); + SpecAssert.Fact(onPattern.Count == 0, + $"Expected no Pattern disagreement on {slice}. Hotspots: {describeHotspots(s)}"); + } + + /// + /// bobcat#501: what this assembly's specs alone say about a GET slice, before the merge -- a Query that reads + /// through ReadsFrom, produces no read model, decides against no aggregate and + /// consumes none of the events its specs arranged. + /// + [Then("this assembly's specs alone say the {slice} slice is a Query reading {type}")] + public void TheSpecsAloneSayTheSliceIsAQueryReading(string slice, string type) + { + var declared = BobcatEventModelSource.Describe().Slices.FirstOrDefault(s => s.Name == slice); + if (declared is null) + { + SpecAssert.Fail($"The specs declare no slice named '{slice}'."); + return; + } + + SpecAssert.Fact(declared.Pattern == SlicePattern.Query, $"The specs say {slice} is {declared.Pattern?.ToString() ?? "null"}, expected Query."); + var reads = declared.ReadsFrom.Select(t => t.Name).ToList(); + SpecAssert.Fact(reads.Contains(type), $"The specs say {slice} reads from [{string.Join(", ", reads)}], expected '{type}'."); + SpecAssert.Fact(declared.ReadModelTypes.Count == 0, + $"The specs say {slice} produces [{string.Join(", ", declared.ReadModelTypes.Select(t => t.Name))}]; a Query reads, it produces nothing."); + SpecAssert.Fact(declared.ConsumedEvents.Count == 0, + $"The specs say {slice} consumes [{string.Join(", ", declared.ConsumedEvents.Select(t => t.Name))}]; a Query's arranged events are context."); + SpecAssert.Fact(declared.AggregateTypes.Count == 0, + $"The specs say {slice} decides against [{string.Join(", ", declared.AggregateTypes.Select(t => t.Name))}]; a Query decides nothing."); + SpecAssert.Fact(declared.PatternViolations.Count == 0, + $"The specs' {slice} Query has pattern violations: {string.Join(" | ", declared.PatternViolations.Select(h => h.Text))}"); + } + + [Then("the {slice} slice reports no source disagreement")] + public void TheSliceReportsNoDisagreement(string slice) + { + if (sliceNamed(slice) is not { } s) return; + SpecAssert.Fact(s.Hotspots.All(h => h.Origin != HotspotOrigin.SourceDisagreement), + $"Unexpected disagreement(s) on {slice}: {describeHotspots(s)}"); + } + + [Then("the {slice} slice is in domain {domain}")] + public void TheSliceIsInDomain(string slice, string domain) + { + if (sliceNamed(slice) is not { } s) return; + SpecAssert.Check("domain", s.Domain, domain); + } + + [Then("the {slice} slice is triggered by {label}")] + public void TheSliceIsTriggeredBy(string slice, string label) + { + if (sliceNamed(slice) is not { } s) return; + SpecAssert.Check("label", s.TriggerLabel, label); + } + + /// + /// Assert which read model a slice reads -- identity, not provenance (bobcat#175). A derived read model + /// could be wrong, ugly or missing while every provenance claim still passed; wolverine#4182 was found by + /// looking at the canvas, which is exactly the kind of catch a spec is supposed to make instead. + /// + [Then("the {slice} slice reads the {readModel} read model")] + public void TheSliceReadsTheReadModel(string slice, string readModel) + { + if (sliceNamed(slice) is not { } s) return; + var names = s.ReadModelTypes.Select(t => t.Name).ToList(); + SpecAssert.Fact(names.Contains(readModel), $"{slice} reads [{string.Join(", ", names)}], expected '{readModel}'."); + } + + /// + /// bobcat#300. Slices merge by name, and the store-derived View slice is named after its document type + /// -- the same name binds. Two slices here means the convention broke and the + /// canvas shows two stickies for one projection. + /// + [Then("there is exactly one slice named {name}")] + public void ThereIsExactlyOneSliceNamed(string name) + { + if (model() is not { } m) return; + var count = m.Slices.Count(s => s.Name == name); + SpecAssert.Fact(count == 1, + $"Expected exactly one slice named '{name}', found {count}. Slices: {string.Join(", ", m.Slices.Select(s => s.Name))}"); + } + + [Then("the {slice} slice has pattern {pattern}")] + public void TheSliceHasPattern(string slice, SlicePattern pattern) + { + if (sliceNamed(slice) is not { } s) return; + SpecAssert.Fact(s.Pattern == pattern, $"{slice}.Pattern is {s.Pattern?.ToString() ?? "null"}, expected {pattern}."); + } + + [Then("the {slice} slice consumes the {eventName} event")] + public void TheSliceConsumes(string slice, string eventName) + { + if (sliceNamed(slice) is not { } s) return; + var names = s.ConsumedEvents.Select(t => t.Name).ToList(); + SpecAssert.Fact(names.Contains(eventName), $"{slice} consumes [{string.Join(", ", names)}], expected '{eventName}'."); + } + + /// + /// bobcat#297: what this assembly's specs say on their own, before the merge -- the generated source + /// read directly. A View spec's arranged events are its ConsumedEvents. + /// + [Then("this assembly's specs alone say the {slice} slice consumes the {eventName} event")] + public void TheSpecsAloneSayTheSliceConsumes(string slice, string eventName) + { + var declared = BobcatEventModelSource.Describe().Slices.FirstOrDefault(s => s.Name == slice); + if (declared is null) + { + SpecAssert.Fail($"The specs declare no slice named '{slice}'."); + return; + } + + var names = declared.ConsumedEvents.Select(t => t.Name).ToList(); + SpecAssert.Fact(names.Contains(eventName), $"The specs say {slice} consumes [{string.Join(", ", names)}], expected '{eventName}'."); + } + + [Then("the {slice} slice reads from {type}")] + public void TheSliceReadsFrom(string slice, string type) + { + if (sliceNamed(slice) is not { } s) return; + var reads = s.ReadsFrom.Select(t => t.Name).ToList(); + SpecAssert.Fact(reads.Contains(type), $"{slice} reads from [{string.Join(", ", reads)}], expected '{type}'."); + } + + /// + /// bobcat#501: the whole ReadsFrom list, in order, written "A, B" -- a Query's arranged GivenEvents stream must not + /// be in it. + /// + [Then("the {slice} slice reads from exactly {types}")] + public void TheSliceReadsFromExactly(string slice, string types) + { + if (sliceNamed(slice) is not { } s) return; + var reads = string.Join(", ", s.ReadsFrom.Select(t => t.Name)); + SpecAssert.Fact(reads == types, $"{slice} reads from [{reads}], expected exactly [{types}]."); + } + + [Then("there is no {kind} link to the {to} slice via {via}")] + public void ThereIsNoLinkTo(EventModelLinkKind kind, string to, string via) + { + if (model() is not { } m) return; + var links = m.Links.Where(l => l.Kind == kind && l.ToSlice == to && l.Via.Name == via).ToList(); + SpecAssert.Fact(links.Count == 0, + $"Unexpected {kind} link(s) to {to} via {via}: {string.Join(" | ", links.Select(l => $"{l.FromSlice} -> {l.ToSlice}"))}"); + } + + [Then("{type} is not an aggregate of the {slice} slice")] + public void IsNotAnAggregateOf(string type, string slice) + { + if (sliceNamed(slice) is not { } s) return; + var aggregates = s.AggregateTypes.Select(t => t.Name).ToList(); + SpecAssert.Fact(!aggregates.Contains(type), $"{slice}'s aggregates are [{string.Join(", ", aggregates)}]; '{type}' should not be one."); + } + + /// + /// bobcat#502: a stream a spec arranges with GivenContext is one the slice reads before deciding -- + /// its ReadsFrom -- and not one of its aggregates. Read off the generated source directly, before the + /// merge. + /// + [Then("this assembly's specs alone say the {slice} slice reads from {type} and does not decide against it")] + public void TheSpecsAloneSayTheSliceReadsFrom(string slice, string type) + { + var declared = BobcatEventModelSource.Describe().Slices.FirstOrDefault(s => s.Name == slice); + if (declared is null) + { + SpecAssert.Fail($"The specs declare no slice named '{slice}'."); + return; + } + + var reads = declared.ReadsFrom.Select(t => t.Name).ToList(); + SpecAssert.Fact(reads.Contains(type), $"The specs say {slice} reads from [{string.Join(", ", reads)}], expected '{type}'."); + + var aggregates = declared.AggregateTypes.Select(t => t.Name).ToList(); + SpecAssert.Fact(!aggregates.Contains(type), + $"The specs say {type} is an aggregate of {slice} ([{string.Join(", ", aggregates)}]); a lookup stream is read from, not decided against."); + } + + /// + /// bobcat#500: the trigger this assembly's specs alone give a slice, before the merge -- so a route the + /// generator resolved from a route name is checked as the specs' own claim, not as whatever Wolverine derived. + /// + [Then("this assembly's specs alone say the {slice} slice is triggered by {label}")] + public void TheSpecsAloneSayTheSliceIsTriggeredAt(string slice, string label) + { + var declared = BobcatEventModelSource.Describe().Slices.FirstOrDefault(s => s.Name == slice); + if (declared is null) + { + SpecAssert.Fail($"The specs declare no slice named '{slice}'."); + return; + } + + SpecAssert.Check("trigger", declared.TriggerOrigin?.Label, label); + } + + /// + /// The cross-slice join computed upstream on read (jasperfx#823): nobody declares a link, it falls out of + /// one slice's EmittedEvents meeting another's ConsumedEvents. + /// + [Then("there is an {kind} link from the {from} slice to the {to} slice via {via}")] + public void ThereIsALink(EventModelLinkKind kind, string from, string to, string via) + { + if (model() is not { } m) return; + var links = m.Links; + SpecAssert.Fact(links.Any(l => l.Kind == kind && l.FromSlice == from && l.ToSlice == to && l.Via.Name == via), + $"No {kind} link {from} -> {to} via {via}. Links: " + + string.Join(" | ", links.Select(l => $"{l.Kind} {l.FromSlice} -> {l.ToSlice} via {l.Via.Name}"))); + } + + [Then("the {slice} slice binds the specification {identity}")] + public void TheSliceBindsTheSpecification(string slice, string identity) + { + if (sliceNamed(slice) is not { } s) return; + var specs = s.Specifications; + SpecAssert.Fact(specs.Any(x => x.Identity == identity), + $"{slice} binds [{string.Join(", ", specs.Select(x => x.Identity))}], expected '{identity}'."); + } + + private static string describeHotspots(EventModelSliceDescriptor slice) + => string.Join(" | ", slice.Hotspots.Select(h => $"{h.Origin}: {h.Text}")); +} diff --git a/src/Samples/Bobcat/BankAccountES/Tests/FreezeAccountSpecs.cs b/src/Samples/Bobcat/BankAccountES/Tests/FreezeAccountSpecs.cs new file mode 100644 index 000000000..4c929260f --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/FreezeAccountSpecs.cs @@ -0,0 +1,31 @@ +using Bobcat.Xunit; + +namespace BankAccountES.Tests; + +/// +/// The one message-handled slice: the fraud desk freezes an account over the Wolverine bus. +/// +/// +/// ⚠️ bobcat#172: this spec deliberately does NOT mention the AccountFlagged event the handler also emits. +/// The Wolverine-derived claim on the slice's emitted events therefore disagrees with this Declared one, and +/// the merge must surface that as a SourceDisagreement hotspot rather than swallow it. Do not "fix" this +/// spec by adding the missing event -- the gap is the point, and asserts on it. +/// ThenEmitted<T> is a contains-check, so the spec passes while naming only AccountFrozen. +/// +// #region freeze-account +[BobcatFeature("Freeze Account")] +public class FreezeAccountSpecs(BankAccountHost app) : BankAccountSpec(app) +{ + [BobcatSpec(typeof(FreezeAccount), Domain = "Banking", TriggeredBy = "The fraud desk")] + public async Task freezing_an_account_records_the_freeze() + { + var accountId = Guid.NewGuid(); + await GivenEvents(accountId, new AccountOpened(accountId, Guid.NewGuid(), "USD")); + + await WhenReceived(new FreezeAccount(accountId, "Suspected fraud")); + + ThenEmitted(); + await ThenReadModel(accountId, Specify().With(x => x.IsFrozen, true)); + } +} +// #endregion freeze-account diff --git a/src/Samples/Bobcat/BankAccountES/Tests/OverdraftRule.cs b/src/Samples/Bobcat/BankAccountES/Tests/OverdraftRule.cs new file mode 100644 index 000000000..f35d7cad9 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/OverdraftRule.cs @@ -0,0 +1,34 @@ +using Wolverine.Http; + +namespace BankAccountES.Tests; + +/// +/// The overdraft rule at the finest grain that tells you something: +/// is a pure function of the command and the account, so it needs no host, no store and no Bobcat. The HTTP +/// specification beside it (withdrawing_more_than_the_balance_leaves_the_account_untouched) proves the +/// wiring once; these prove the rule. The "Test Automation Strategy Recommendations" tutorial pulls them in. +/// +public class OverdraftRule +{ + // #region overdraft-rule + [Fact] + public void a_withdrawal_larger_than_the_balance_is_refused() + { + var account = new Account { Balance = 100m }; + + var problems = WithdrawFundsEndpoint.Validate(new WithdrawFunds(Guid.NewGuid(), 200m), account); + + Assert.Equal(400, problems.Status); + } + + [Fact] + public void a_withdrawal_of_the_whole_balance_is_allowed() + { + var account = new Account { Balance = 100m }; + + var problems = WithdrawFundsEndpoint.Validate(new WithdrawFunds(Guid.NewGuid(), 100m), account); + + Assert.Same(WolverineContinue.NoProblems, problems); + } + // #endregion overdraft-rule +} diff --git a/src/Samples/Bobcat/BankAccountES/Tests/Tests.csproj b/src/Samples/Bobcat/BankAccountES/Tests/Tests.csproj new file mode 100644 index 000000000..5d379a207 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/Tests.csproj @@ -0,0 +1,30 @@ + + + + + Exe + net10.0 + enable + enable + true + + BankAccountES.Tests + BankAccountES.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/BankAccountES/Tests/Usings.cs b/src/Samples/Bobcat/BankAccountES/Tests/Usings.cs new file mode 100644 index 000000000..6602f579b --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/Tests/Usings.cs @@ -0,0 +1,4 @@ +global using Bobcat; +global using Bobcat.CritterStack; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/BankAccountES/TransactionHistory.cs b/src/Samples/Bobcat/BankAccountES/TransactionHistory.cs new file mode 100644 index 000000000..d1023bd95 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/TransactionHistory.cs @@ -0,0 +1,99 @@ +using JasperFx.Events; +using JasperFx.Events.Documents; +using Wolverine.Http; + +namespace BankAccountES; + +/// +/// Read model: a transaction history per account, built from deposit and withdrawal events. +/// Replaces the Java sample's commutative TransactionProjection that used version-based ordering — +/// the event store's sequence numbers already guarantee the order, whichever store it is. +/// +/// +/// This used to be a SingleStreamProjection<AccountTransactions, Guid> subclass, which +/// is a Marten base class (Fisher and Polecat each have their own). A self-aggregating document +/// registered with Snapshot<AccountTransactions>(SnapshotLifecycle.Inline) is the one +/// shape of projection every store spells identically, so the read model moved its Create / +/// Apply conventions onto itself and the projection class is gone. Same generator, same +/// dispatch, no store in the signature. The event timestamp comes from +/// metadata rather than the clock, so a rebuilt read model carries the original times. +/// +public class AccountTransactions +{ + public Guid Id { get; set; } // AccountId + public List Transactions { get; set; } = []; + public decimal Balance { get; set; } + + public static AccountTransactions Create(AccountOpened e) => new() { Id = e.AccountId }; + + public void Apply(IEvent e) + { + Balance = e.Data.NewBalance; + Transactions.Add(new Transaction + { + Type = "Deposit", + Amount = e.Data.Amount, + BalanceAfter = e.Data.NewBalance, + Timestamp = e.Timestamp, + }); + } + + public void Apply(IEvent e) + { + Balance = e.Data.NewBalance; + Transactions.Add(new Transaction + { + Type = "Withdrawal", + Amount = e.Data.Amount, + BalanceAfter = e.Data.NewBalance, + Timestamp = e.Timestamp, + }); + } +} + +public class Transaction +{ + public string Type { get; set; } = string.Empty; + public decimal Amount { get; set; } + public decimal BalanceAfter { get; set; } + public DateTimeOffset Timestamp { get; set; } +} + +// --- Query endpoints --- +// +// Reads go through the JasperFx.Events contracts Wolverine injects for every store: +// IDocumentReadOperations for documents (the inline snapshots), IEventStoreOperations for the +// stream itself. Marten's IQuerySession and Fisher's both implement them, and Wolverine resolves +// either to the current session, so these endpoints have no `using Marten` and no `using Fisher`. + +public static class GetTransactionsEndpoint +{ + [WolverineGet("/api/accounts/{accountId}/transactions")] + public static Task Get(Guid accountId, IDocumentReadOperations reads, CancellationToken ct) + => reads.LoadAsync(accountId, ct); +} + +public static class GetAccountEndpoint +{ + // Rebuilt from the stream on every read — the point of the sample is that the aggregate IS its + // events; the inline snapshot is there for [DeciderFunction] and [Entity] to load cheaply. + [WolverineGet("/api/accounts/{id}")] + public static Task Get(Guid id, IEventStoreOperations events, CancellationToken ct) + => events.AggregateStreamAsync(id, token: ct); +} + +public static class GetClientEndpoint +{ + [WolverineGet("/api/clients/{id}")] + public static Task Get(Guid id, IEventStoreOperations events, CancellationToken ct) + => events.AggregateStreamAsync(id, token: ct); +} + +public static class GetClientAccountsEndpoint +{ + // LINQ over the Account snapshots, through the store-agnostic IQueryable and JasperFx.Events' + // own ToListAsync — Marten's LINQ on Postgres, Fisher's on SQLite, same line. + [WolverineGet("/api/clients/{clientId}/accounts")] + public static Task> Get(Guid clientId, IDocumentReadOperations reads, CancellationToken ct) + => reads.Query().Where(a => a.ClientId == clientId).ToListAsync(ct); +} diff --git a/src/Samples/Bobcat/BankAccountES/UpdateClient.cs b/src/Samples/Bobcat/BankAccountES/UpdateClient.cs new file mode 100644 index 000000000..22dd6fda4 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/UpdateClient.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using Wolverine.Http; +using Wolverine.Persistence.EventSourcing; + +namespace BankAccountES; + +public record UpdateClient(Guid ClientId, string Name, string Email) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + } + } +} + +public static class UpdateClientEndpoint +{ + // [DeciderFunction] is the store-agnostic spelling of Wolverine.Marten's [AggregateHandler] + // (which now derives from it): load the Client stream, hand the current state in, append what + // comes back. Same attribute, whichever store Program.cs registered. + // RouteName is what a spec can address the endpoint by instead of its URL (bobcat#500): the Bobcat + // generator resolves it to this template at compile time, from this assembly's metadata. + [WolverinePut("/api/clients/{clientId}", RouteName = "UpdateClient")] + [DeciderFunction] + public static (IResult, ClientUpdated) Put(UpdateClient command, Client client) + { + return (Results.NoContent(), new ClientUpdated(command.ClientId, command.Name, command.Email)); + } +} diff --git a/src/Samples/Bobcat/BankAccountES/WithdrawFunds.cs b/src/Samples/Bobcat/BankAccountES/WithdrawFunds.cs new file mode 100644 index 000000000..2a7913892 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/WithdrawFunds.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; +using Wolverine.Persistence.EventSourcing; + +namespace BankAccountES; + +public record WithdrawFunds(Guid AccountId, decimal Amount) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.AccountId).NotEmpty(); + RuleFor(x => x.Amount).GreaterThan(0); + } + } +} + +public static class WithdrawFundsEndpoint +{ + // Sad path: insufficient funds check using the loaded aggregate + public static ProblemDetails Validate(WithdrawFunds command, Account account) + { + if (account.Balance < command.Amount) + return new ProblemDetails + { + Detail = $"Insufficient funds. Balance: {account.Balance:C}, requested: {command.Amount:C}", + Status = 400, + }; + + return WolverineContinue.NoProblems; + } + + [WolverinePost("/api/accounts/{accountId}/withdrawals")] + [DeciderFunction] + public static (IResult, FundsWithdrawn) Post(WithdrawFunds command, Account account) + { + var newBalance = account.Balance - command.Amount; + return (Results.NoContent(), new FundsWithdrawn(command.AccountId, command.Amount, newBalance)); + } +} diff --git a/src/Samples/Bobcat/BankAccountES/appsettings.json b/src/Samples/Bobcat/BankAccountES/appsettings.json new file mode 100644 index 000000000..ea0a1b888 --- /dev/null +++ b/src/Samples/Bobcat/BankAccountES/appsettings.json @@ -0,0 +1,14 @@ +{ + "EventStore": "Marten", + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=bank_account;Username=postgres;Password=postgres", + "Fisher": "Data Source=bank_account.db" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/BookingMonolith/Booking/Booking.cs b/src/Samples/Bobcat/BookingMonolith/Booking/Booking.cs new file mode 100644 index 000000000..5a360eee7 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Booking/Booking.cs @@ -0,0 +1,48 @@ +using Marten.Events.Aggregation; + +namespace Booking; + +// --- Domain Events (stored in Marten event store) --- +// Replaces EventStoreDB streams + MongoDB read model projections + +public record BookingCreatedDomainEvent( + Guid BookingId, + Guid PassengerId, + string PassengerName, + Guid FlightId, + string FlightNumber, + decimal Price, + string? Description, + string? SeatNumber +); + +/// +/// Booking aggregate — event-sourced via Marten. +/// Replaces EventStoreDB + IEventStoreDBRepository + AggregateEventSourcing base class. +/// Marten rebuilds state by calling Apply methods automatically. +/// +public class BookingRecord +{ + public Guid Id { get; set; } + public Guid PassengerId { get; set; } + public string PassengerName { get; set; } = string.Empty; + public Guid FlightId { get; set; } + public string FlightNumber { get; set; } = string.Empty; + public decimal Price { get; set; } + public string? Description { get; set; } + public string? SeatNumber { get; set; } + public DateTimeOffset CreatedAt { get; set; } + + public void Apply(BookingCreatedDomainEvent e) + { + Id = e.BookingId; + PassengerId = e.PassengerId; + PassengerName = e.PassengerName; + FlightId = e.FlightId; + FlightNumber = e.FlightNumber; + Price = e.Price; + Description = e.Description; + SeatNumber = e.SeatNumber; + CreatedAt = DateTimeOffset.UtcNow; + } +} diff --git a/src/Samples/Bobcat/BookingMonolith/Booking/CreateBooking.cs b/src/Samples/Bobcat/BookingMonolith/Booking/CreateBooking.cs new file mode 100644 index 000000000..2e8fd6078 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Booking/CreateBooking.cs @@ -0,0 +1,82 @@ +using FluentValidation; +using Marten; +using BookingMonolith; +using Flight; +using Microsoft.AspNetCore.Http.HttpResults; +using Passenger; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Booking; + +public record CreateBooking(Guid PassengerId, Guid FlightId, string? Description) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.PassengerId).NotEmpty(); + RuleFor(x => x.FlightId).NotEmpty(); + } + } +} + +public static class CreateBookingEndpoint +{ + // Event-sourced: starts a new event stream in Marten. + // Replaces: gRPC calls to Flight/Passenger + EventStoreDB append + MongoDB projection + [WolverinePost("/api/bookings")] + + // Multiple [Entity] parameters enable Marten batch querying — Wolverine loads + // both the PassengerRecord and FlightRecord in a single database round-trip + // instead of two sequential LoadAsync calls. This is a significant performance win. + // OnMissing = ProblemDetailsWith400 treats missing referenced entities as bad input + // (the IDs come from the client), eliminating the need for a separate ValidateAsync. + // + // First tuple item is the HTTP response (an IResult, executed as-is); the rest are cascaded + // messages. See RegisterUserEndpoint. + public static (Created, BookingCreated) Post( + CreateBooking command, + [Entity(Required = true, OnMissing = OnMissing.ProblemDetailsWith400)] Passenger.Passenger passenger, + [Entity(Required = true, OnMissing = OnMissing.ProblemDetailsWith400)] Flight.Flight flight, + IDocumentSession session) + { + var bookingId = Guid.NewGuid(); + + var domainEvent = new BookingCreatedDomainEvent( + bookingId, + passenger.Id, + passenger.Name, + flight.Id, + flight.FlightNumber, + flight.Price, + command.Description, + SeatNumber: null + ); + + // Start an event stream — Marten stores the event and + // the BookingRecord snapshot is built via Apply methods + session.Events.StartStream(bookingId, domainEvent); + + // Build the snapshot manually for the response + var booking = new BookingRecord(); + booking.Apply(domainEvent); + + return ( + TypedResults.Created($"/api/bookings/{bookingId}", booking), + new BookingCreated(bookingId, passenger.Id, flight.Id)); + } +} + +public static class GetBookingsEndpoint +{ + [WolverineGet("/api/bookings")] + public static Task> Get(IQuerySession session, CancellationToken ct) + => session.Query().ToListAsync(ct); +} + +public static class GetBookingByIdEndpoint +{ + [WolverineGet("/api/bookings/{id}")] + public static BookingRecord? Get(Guid id, [Wolverine.Persistence.Entity] BookingRecord? booking) => booking; +} diff --git a/src/Samples/Bobcat/BookingMonolith/BookingMonolith.csproj b/src/Samples/Bobcat/BookingMonolith/BookingMonolith.csproj new file mode 100644 index 000000000..810cbebf4 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/BookingMonolith.csproj @@ -0,0 +1,33 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/BookingMonolith/BookingMonolith.slnx b/src/Samples/Bobcat/BookingMonolith/BookingMonolith.slnx new file mode 100644 index 000000000..94f1b528c --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/BookingMonolith.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Samples/Bobcat/BookingMonolith/Flight/CreateFlight.cs b/src/Samples/Bobcat/BookingMonolith/Flight/CreateFlight.cs new file mode 100644 index 000000000..1f2cf1bdf --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Flight/CreateFlight.cs @@ -0,0 +1,73 @@ +using FluentValidation; +using Marten; +using BookingMonolith; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; + +namespace Flight; + +public record CreateFlight( + string FlightNumber, + Guid AircraftId, + Guid DepartureAirportId, + Guid ArriveAirportId, + int DurationMinutes, + decimal Price, + DateTime DepartureDate, + DateTime ArriveDate, + DateTime FlightDate +) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.FlightNumber).NotEmpty(); + RuleFor(x => x.Price).GreaterThan(0); + RuleFor(x => x.DepartureDate).LessThan(x => x.ArriveDate); + } + } +} + +public static class CreateFlightEndpoint +{ + // First tuple item is the HTTP response (an IResult, executed as-is); the rest are cascaded + // messages. See RegisterUserEndpoint. + [WolverinePost("/api/flights")] + public static (Created, FlightCreated) Post(CreateFlight command, IDocumentSession session) + { + var flight = new Flight + { + Id = Guid.NewGuid(), + FlightNumber = command.FlightNumber, + AircraftId = command.AircraftId, + DepartureAirportId = command.DepartureAirportId, + ArriveAirportId = command.ArriveAirportId, + DurationMinutes = command.DurationMinutes, + Price = command.Price, + DepartureDate = command.DepartureDate, + ArriveDate = command.ArriveDate, + FlightDate = command.FlightDate, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(flight); + + return ( + TypedResults.Created($"/api/flights/{flight.Id}", flight), + new FlightCreated(flight.Id, flight.FlightNumber, flight.Price, flight.FlightDate)); + } +} + +public static class GetFlightsEndpoint +{ + [WolverineGet("/api/flights")] + public static Task> Get(IQuerySession session, CancellationToken ct) + => session.Query().OrderBy(f => f.FlightDate).ToListAsync(ct); +} + +public static class GetFlightByIdEndpoint +{ + [WolverineGet("/api/flights/{id}")] + public static Flight? Get(Guid id, [Wolverine.Persistence.Entity] Flight? flight) => flight; +} diff --git a/src/Samples/Bobcat/BookingMonolith/Flight/Flight.cs b/src/Samples/Bobcat/BookingMonolith/Flight/Flight.cs new file mode 100644 index 000000000..c3d516767 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Flight/Flight.cs @@ -0,0 +1,48 @@ +namespace Flight; + +public class Flight +{ + public Guid Id { get; set; } + public string FlightNumber { get; set; } = string.Empty; + public Guid AircraftId { get; set; } + public Guid DepartureAirportId { get; set; } + public Guid ArriveAirportId { get; set; } + public int DurationMinutes { get; set; } + public decimal Price { get; set; } + public FlightStatus Status { get; set; } = FlightStatus.Active; + public DateTime DepartureDate { get; set; } + public DateTime ArriveDate { get; set; } + public DateTime FlightDate { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} + +public enum FlightStatus { Active, Completed, Cancelled, Delayed } + +public class Aircraft +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public int ManufacturingYear { get; set; } +} + +public class Airport +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public string Address { get; set; } = string.Empty; +} + +public class Seat +{ + public Guid Id { get; set; } + public string SeatNumber { get; set; } = string.Empty; + public Guid FlightId { get; set; } + public SeatType Type { get; set; } = SeatType.Window; + public SeatClass Class { get; set; } = SeatClass.Economy; + public bool IsReserved { get; set; } +} + +public enum SeatType { Window, Middle, Aisle } +public enum SeatClass { Economy, Business, FirstClass } diff --git a/src/Samples/Bobcat/BookingMonolith/Identity/RegisterUser.cs b/src/Samples/Bobcat/BookingMonolith/Identity/RegisterUser.cs new file mode 100644 index 000000000..32242f52b --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Identity/RegisterUser.cs @@ -0,0 +1,66 @@ +using FluentValidation; +using BookingMonolith; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Identity; + +public class UserAccount +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } +} + +public record RegisterUser(string Email, string FirstName, string LastName, string Password) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8); + } + } +} + +public static class RegisterUserEndpoint +{ + // Created rather than a bare UserAccount, so the response carries the 201 and Location a + // newly-created resource should. By Wolverine.Http's tuple convention the first item is the + // HTTP response — an IResult is executed as-is — and every item after it is a cascaded + // message, which is how UserCreated reaches the Passenger module's durable local queue. + [WolverinePost("/api/identity/register")] + public static (Created, UserCreated) Post(RegisterUser command, Marten.IDocumentSession session) + { + var user = new UserAccount + { + Id = Guid.NewGuid(), + Email = command.Email, + FirstName = command.FirstName, + LastName = command.LastName, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(user); + return ( + TypedResults.Created($"/api/identity/{user.Id}", user), + new UserCreated(user.Id, user.Email, user.FirstName, user.LastName)); + } +} + +/// +/// Reading a user back was missing entirely — the module could only be written to. Without it +/// the registration spec could assert nothing beyond "a Guid came back", and the Location header +/// the POST now emits would point at a route that did not exist. +/// +public static class GetUserEndpoint +{ + [WolverineGet("/api/identity/{id}")] + public static UserAccount? Get(Guid id, [Entity] UserAccount? user) => user; +} diff --git a/src/Samples/Bobcat/BookingMonolith/IntegrationEvents.cs b/src/Samples/Bobcat/BookingMonolith/IntegrationEvents.cs new file mode 100644 index 000000000..5d56f8629 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/IntegrationEvents.cs @@ -0,0 +1,10 @@ +namespace BookingMonolith; + +// Integration events for cross-module communication. +// Replaces: EventDispatcher + IEventMapper + PersistMessageProcessor + +// outbox/inbox tables + MassTransit consumers + gRPC inter-module calls + +public record UserCreated(Guid UserId, string Email, string FirstName, string LastName); +public record PassengerCreated(Guid PassengerId, string Name); +public record FlightCreated(Guid FlightId, string FlightNumber, decimal Price, DateTime FlightDate); +public record BookingCreated(Guid BookingId, Guid PassengerId, Guid FlightId); diff --git a/src/Samples/Bobcat/BookingMonolith/Passenger/CreatePassenger.cs b/src/Samples/Bobcat/BookingMonolith/Passenger/CreatePassenger.cs new file mode 100644 index 000000000..8cc5bf33c --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Passenger/CreatePassenger.cs @@ -0,0 +1,73 @@ +using FluentValidation; +using Marten; +using BookingMonolith; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Passenger; + +public record CreatePassenger(string Name, string PassportNumber, PassengerType Type, int Age) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.PassportNumber).NotEmpty(); + } + } +} + +public static class CreatePassengerEndpoint +{ + // First tuple item is the HTTP response (an IResult, executed as-is); the rest are cascaded + // messages. See RegisterUserEndpoint. + [WolverinePost("/api/passengers")] + public static (Created, PassengerCreated) Post(CreatePassenger command, IDocumentSession session) + { + var passenger = new Passenger + { + Id = Guid.NewGuid(), + Name = command.Name, + PassportNumber = command.PassportNumber, + Type = command.Type, + Age = command.Age, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(passenger); + return ( + TypedResults.Created($"/api/passengers/{passenger.Id}", passenger), + new PassengerCreated(passenger.Id, passenger.Name)); + } +} + +/// +/// Reading a passenger back was missing entirely — the module could only be written to, by the +/// POST above and by the UserCreated cascade below. Without it there is no way to observe that +/// registering a user really does create the passenger stub, which is the one place this +/// monolith's modules talk to each other. +/// +public static class GetPassengerByIdEndpoint +{ + [WolverineGet("/api/passengers/{id}")] + public static Passenger? Get(Guid id, [Entity] Passenger? passenger) => passenger; +} + +/// +/// Handles UserCreated from the Identity module — auto-creates a passenger stub. +/// Replaces: MassTransit IConsumer + RegisterNewUserHandler +/// +public static class UserCreatedHandler +{ + public static void Handle(UserCreated message, IDocumentSession session) + { + session.Store(new Passenger + { + Id = message.UserId, + Name = $"{message.FirstName} {message.LastName}", + CreatedAt = DateTimeOffset.UtcNow, + }); + } +} diff --git a/src/Samples/Bobcat/BookingMonolith/Passenger/Passenger.cs b/src/Samples/Bobcat/BookingMonolith/Passenger/Passenger.cs new file mode 100644 index 000000000..d98ce6696 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Passenger/Passenger.cs @@ -0,0 +1,13 @@ +namespace Passenger; + +public class Passenger +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string PassportNumber { get; set; } = string.Empty; + public PassengerType Type { get; set; } = PassengerType.Unknown; + public int Age { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} + +public enum PassengerType { Unknown, Male, Female, Baby, Child } diff --git a/src/Samples/Bobcat/BookingMonolith/Program.cs b/src/Samples/Bobcat/BookingMonolith/Program.cs new file mode 100644 index 000000000..77207a35a --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Program.cs @@ -0,0 +1,73 @@ +using BookingMonolith; +using JasperFx.Events.Projections; +using Marten; +using Marten.Events.Projections; +using Wolverine; +using Wolverine.FluentValidation; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=booking;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + + // Schema-per-module + opts.Schema.For().DatabaseSchemaName("identity"); + opts.Schema.For().DatabaseSchemaName("passenger"); + opts.Schema.For().DatabaseSchemaName("flight"); + opts.Schema.For().DatabaseSchemaName("flight"); + opts.Schema.For().DatabaseSchemaName("flight"); + opts.Schema.For().DatabaseSchemaName("flight"); + + // Event sourcing for the Booking module — replaces EventStoreDB + MongoDB + opts.Events.DatabaseSchemaName = "booking"; + opts.Projections.Snapshot(SnapshotLifecycle.Inline); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); + opts.ServiceName = "BookingMonolith"; + + // Durable local queues for inter-module messaging + opts.LocalQueue("identity").UseDurableInbox(); + opts.LocalQueue("passenger").UseDurableInbox(); + opts.LocalQueue("flight").UseDurableInbox(); + opts.LocalQueue("booking").UseDurableInbox(); + + // Route integration events + opts.Publish(x => { x.Message(); x.ToLocalQueue("passenger"); }); + opts.Publish(x => { x.Message(); x.ToLocalQueue("booking"); }); + opts.Publish(x => { x.Message(); x.ToLocalQueue("booking"); }); + opts.Publish(x => { x.Message(); x.ToLocalQueue("flight"); }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +await app.RunAsync(); diff --git a/src/Samples/Bobcat/BookingMonolith/README.md b/src/Samples/Bobcat/BookingMonolith/README.md new file mode 100644 index 000000000..3e3821ee3 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/README.md @@ -0,0 +1,60 @@ +# Booking Modular Monolith — Critter Stack Conversion + +## Original Project + +**Repository:** [meysamhadeli/booking-modular-monolith](https://github.com/meysamhadeli/booking-modular-monolith) +**License:** MIT +**Stack:** .NET 10, MassTransit, MediatR, FluentValidation, EventStoreDB (event sourcing), EF Core (PostgreSQL), MongoDB (read models), gRPC (inter-module), Duende IdentityServer, .NET Aspire, Testcontainers + +The richest original stack of all conversions. A travel booking system with 4 modules, event sourcing in the Booking module via EventStoreDB, MongoDB for read model projections, and gRPC for synchronous inter-module communication. + +## What Changed + +### Removed (7 infrastructure dependencies) +- **EventStoreDB** — replaced by Marten event store (same PostgreSQL) +- **MongoDB** — read model projections replaced by Marten inline snapshots +- **MassTransit** — replaced by Wolverine messaging + durable local queues +- **MediatR** — replaced by Wolverine handlers +- **gRPC inter-module calls** — replaced by direct Marten queries (same process) +- **Duende IdentityServer** — simplified to basic user registration +- **Custom outbox/inbox** — PersistMessageProcessor + ConsumeFilter replaced by Wolverine outbox + +### Before vs After + +| Aspect | Original | Converted | +|--------|----------|-----------| +| Projects | ~15 (modules × layers + BuildingBlocks + Aspire) | 1 | +| Databases | PostgreSQL + EventStoreDB + MongoDB | PostgreSQL only (Marten) | +| Event store | EventStoreDB (gRPC client) | Marten event store | +| Read models | MongoDB projections + EventStore subscriptions | Marten inline snapshots | +| Message bus | MassTransit (RabbitMQ/InMemory) | Wolverine durable local queues | +| CQRS | MediatR + pipeline behaviors | Wolverine handlers | +| Inter-module sync | gRPC proto + generated clients | Direct Marten queries | +| Identity | Duende IdentityServer | Simple user registration | + +### Event Sourcing (Booking module) + +The original used EventStoreDB with a custom `AggregateEventSourcing` base class, stream naming conventions, MongoDB projections, and EventStore subscription checkpoints. + +Marten replaces all of this with: +```csharp +// Event stream creation +session.Events.StartStream(bookingId, new BookingCreatedDomainEvent(...)); + +// Inline snapshot projection — no separate MongoDB read model needed +opts.Projections.Snapshot(SnapshotLifecycle.Inline); +``` + +### Inter-Module Communication + +The original used gRPC for synchronous calls (Booking → Flight, Booking → Passenger). In a monolith, these are unnecessary — the Booking handler queries Marten directly since all modules share the same process. + +## Running + +Requires PostgreSQL only (no EventStoreDB, MongoDB, or RabbitMQ). + +```bash +dotnet run +``` + +Swagger UI at `/swagger`. diff --git a/src/Samples/Bobcat/BookingMonolith/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/BookingMonolith/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..45a8ff613 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Tests/AssemblyAttributes.cs @@ -0,0 +1,24 @@ +using BookingMonolith.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(BookingMonolithHost))] + +// Every spec empties the store before it runs (see BookingMonolithSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tells ASP.NET Core's WebApplicationFactory (which Alba uses) where the host's content root +// is. Without it, discovery walks up from the test assembly's bin directory and synthesizes +// `samples/BookingMonolith/BookingMonolith/` from the host assembly name — a folder that does +// not exist — and throws DirectoryNotFoundException. The Tests project lives INSIDE the host +// project rather than beside it, which is not the layout the default assumes. +// +// The path is relative to the test assembly's bin directory at run time: from +// `samples/BookingMonolith/Tests/bin//net10.0/`, four levels up is +// `samples/BookingMonolith/`, where the host's appsettings.json lives. +[assembly: WebApplicationFactoryContentRoot( + "BookingMonolith", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/BookingMonolith/Tests/BookingMonolithHost.cs b/src/Samples/Bobcat/BookingMonolith/Tests/BookingMonolithHost.cs new file mode 100644 index 000000000..cf9a623c6 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Tests/BookingMonolithHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace BookingMonolith.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class BookingMonolithHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/BookingMonolith/Tests/BookingMonolithSpec.cs b/src/Samples/Bobcat/BookingMonolith/Tests/BookingMonolithSpec.cs new file mode 100644 index 000000000..dcf48217f --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Tests/BookingMonolithSpec.cs @@ -0,0 +1,82 @@ +using Alba; +using Wolverine; +using Wolverine.Tracking; + +namespace BookingMonolith.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every BookingMonolith spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// +/// It is Bobcat's store-only as well, so a spec can assert what an act +/// appended to the event store and what the aggregate became. A When step goes through +/// , which takes the store's high-water mark, runs the request, +/// and reads back everything appended since -- so ThenEvents and ThenNoEvents judge exactly +/// what that request appended, cascades included. +/// +/// +/// The HTTP status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a +/// spec asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +/// +public abstract class BookingMonolithSpec(BookingMonolithHost app) : CritterStackSpec(app.Host), IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // Every spec mints fresh ids, so the reset is not what makes the suite pass twice; it keeps the document tables and the booking event store from growing without bound. ResetEventStoresAsync empties both, documents AND streams. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } + + /// + /// Run an HTTP call and wait for every message it cascades to be fully handled. The modules in this + /// sample talk over UseDurableInbox() local queues, so the response goes out before the next + /// module has handled the cascade; asserting straight off it would race the handler. + /// + protected async Task> TrackedAsync(Func>> call) + { + HttpResult? captured = null; + + // Explicitly typed: ExecuteAndWaitAsync overloads on Task and ValueTask, and an async lambda is + // convertible to both. + Func act = async _ => { captured = await call(); }; + + await Alba.TrackActivity() + .Timeout(TimeSpan.FromSeconds(30)) + .ExecuteAndWaitAsync(act); + + return captured!; + } +} diff --git a/src/Samples/Bobcat/BookingMonolith/Tests/BookingSpecs.cs b/src/Samples/Bobcat/BookingMonolith/Tests/BookingSpecs.cs new file mode 100644 index 000000000..6e9e65567 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Tests/BookingSpecs.cs @@ -0,0 +1,377 @@ +using Booking; +using Identity; +using FlightRecord = global::Flight.Flight; +using PassengerRecord = global::Passenger.Passenger; + +namespace BookingMonolith.Tests; + +/// +/// The booking-modular-monolith conversion: four modules (Identity, Passenger, Flight, Booking) in one +/// process. Identity and Passenger talk only over a durable local queue -- registering a user makes the +/// Passenger module create a passenger stub, and that is the one place the modules touch. Booking is +/// event-sourced in Marten with an inline snapshot, so the booking specs assert the event the request +/// appended and the aggregate rebuilt from its stream, not a row the POST wrote. +/// +/// +/// Reuses the host's own command and document types via the project reference. RegisterUser requires a +/// first and last name, and CreatePassenger a passport number, which no spec is about; the host has no +/// airport or aircraft endpoints, so the flight number is the only human-readable identity a flight has. +/// +[BobcatFeature("Booking Monolith")] +public class BookingSpecs(BookingMonolithHost app) : BookingMonolithSpec(app) +{ + private const string FirstName = "Spec"; + private const string LastName = "User"; + private const string PassportNumber = "SPEC-0001"; + + private Guid _userId; + private Guid _passengerId; + private Guid _flightId; + private Guid _bookingId; + private int _lastStatusCode; + private List _flights = []; + private List _bookings = []; + + // ---- identity --------------------------------------------------------------------------- + + [BobcatSpec] + public async Task register_a_user() + { + await WhenAUserRegisters("booking.user@example.com", "Password1!"); + + ThenTheResponseStatusIs(201); + await ThenTheStoredUserHasEmail("booking.user@example.com"); + } + + [BobcatSpec] + public async Task registering_a_user_creates_a_passenger_stub() + { + await WhenAUserRegisters("stub.user@example.com", "Password1!"); + + await ThenAPassengerStubExistsForThatUser(); + } + + [BobcatSpec] + public async Task register_with_bad_email_returns_400() + { + await WhenAUserRegisters("not-an-email", "Password1!"); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task register_with_short_password_returns_400() + { + await WhenAUserRegisters("shortpwd@example.com", "abc"); + + ThenTheResponseStatusIs(400); + } + + // ---- passengers ------------------------------------------------------------------------- + + [BobcatSpec] + public async Task create_a_passenger() + { + await WhenAPassengerIsCreated("John Traveler", 30); + + ThenTheResponseStatusIs(201); + await ThenTheStoredPassengerIs("John Traveler", 30); + } + + [BobcatSpec] + public async Task create_passenger_with_empty_name_returns_400() + { + await WhenAPassengerIsCreated("", 25); + + ThenTheResponseStatusIs(400); + } + + // ---- flights ---------------------------------------------------------------------------- + + [BobcatSpec] + public async Task create_a_flight() + { + await WhenAFlightIsCreated("NYC-LAX", 299.99m); + + ThenTheResponseStatusIs(201); + await ThenTheStoredFlightIs("NYC-LAX", 299.99m); + } + + [BobcatSpec] + public async Task create_flight_with_zero_price_returns_400() + { + await WhenAFlightIsCreated("BOS-SFO", 0m); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task get_flights() + { + await GivenAFlight("ORD-MIA", 199.99m); + + await WhenAllFlightsAreRead(); + + ThenTheFlightsInclude("ORD-MIA"); + } + + [BobcatSpec] + public async Task get_flight_by_id() + { + await GivenAFlight("SEA-DEN", 149.99m); + + await WhenTheFlightIsRead(_flightId); + + ThenTheResponseStatusIs(200); + } + + [BobcatSpec] + public async Task get_flight_by_id_returns_404_for_missing() + { + await WhenTheFlightIsRead(Guid.Empty); + + ThenTheResponseStatusIs(404); + } + + // ---- bookings: event-sourced ------------------------------------------------------------ + + [BobcatSpec] + public async Task create_a_booking() + { + await GivenAPassenger("Booker Passenger", 28); + await GivenAFlight("ATL-PHX", 179.99m); + + await WhenTheFlightIsBookedForThePassenger(); + + ThenTheResponseStatusIs(201); + ThenEvents(Specify() + .With(x => x.PassengerName, "Booker Passenger") + .With(x => x.FlightNumber, "ATL-PHX") + .With(x => x.Price, 179.99m)); + await ThenAggregate(TheStartedStream(), Specify() + .With(x => x.PassengerName, "Booker Passenger") + .With(x => x.FlightNumber, "ATL-PHX") + .With(x => x.Price, 179.99m)); + await ThenTheStoredBookingIs("Booker Passenger", "ATL-PHX", 179.99m); + } + + [BobcatSpec] + public async Task booking_for_a_passenger_that_does_not_exist_returns_400() + { + await GivenAFlight("DTW-LAS", 159.99m); + + await WhenTheFlightIsBookedForAnUnknownPassenger(); + + ThenTheResponseStatusIs(400); + ThenNoEvents(); + } + + [BobcatSpec] + public async Task booking_a_flight_that_does_not_exist_returns_400() + { + await GivenAPassenger("No Flight Passenger", 35); + + await WhenAnUnknownFlightIsBookedForThePassenger(); + + ThenTheResponseStatusIs(400); + ThenNoEvents(); + } + + [BobcatSpec] + public async Task get_bookings() + { + await GivenAPassenger("Get Booker Passenger", 22); + await GivenAFlight("CLT-SLC", 189.99m); + await GivenTheFlightWasBookedForThePassenger(); + + await WhenAllBookingsAreRead(); + + ThenTheBookingsIncludeTheNewBooking(); + } + + [BobcatSpec] + public async Task get_booking_by_id() + { + await GivenAPassenger("ById Passenger", 40); + await GivenAFlight("MSP-TPA", 210.00m); + await GivenTheFlightWasBookedForThePassenger(); + + await WhenTheBookingIsRead(); + + ThenTheResponseStatusIs(200); + } + + // ---- identity steps --------------------------------------------------------------------- + + // A When step goes through WhenActing, so what the request appended (cascades included) is what + // ThenEvents and ThenNoEvents judge. A Given step is a plain call: it arranges, it is not the act. + public Task WhenAUserRegisters(string email, string password) + => WhenActing($"a user registers with email \"{email}\" and password \"{password}\"", _ => register(email, password)); + + private async Task register(string email, string password) + { + var result = await TrackedAsync(() => PostJsonAsync( + "/api/identity/register", new RegisterUser(email, FirstName, LastName, password))); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _userId = result.Body.Id; + } + + // ---- passenger steps -------------------------------------------------------------------- + + [Given("a passenger named \"{name}\" aged {age}")] + public Task GivenAPassenger(string name, int age) => createPassenger(name, age); + + public Task WhenAPassengerIsCreated(string name, int age) + => WhenActing($"a passenger named \"{name}\" aged {age} is created", _ => createPassenger(name, age)); + + private async Task createPassenger(string name, int age) + { + var result = await TrackedAsync(() => PostJsonAsync( + "/api/passengers", + new global::Passenger.CreatePassenger(name, PassportNumber, global::Passenger.PassengerType.Unknown, age))); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _passengerId = result.Body.Id; + } + + // ---- flight steps ----------------------------------------------------------------------- + + [Given("flight {flightNumber} priced {price}")] + public Task GivenAFlight(string flightNumber, decimal price) => createFlight(flightNumber, price); + + public Task WhenAFlightIsCreated(string flightNumber, decimal price) + => WhenActing($"flight {flightNumber} priced {price} is created", _ => createFlight(flightNumber, price)); + + private async Task createFlight(string flightNumber, decimal price) + { + var departs = DateTime.UtcNow.AddDays(30); + var command = new global::Flight.CreateFlight( + FlightNumber: flightNumber, + AircraftId: Guid.NewGuid(), + DepartureAirportId: Guid.NewGuid(), + ArriveAirportId: Guid.NewGuid(), + DurationMinutes: 300, + Price: price, + DepartureDate: departs, + ArriveDate: departs.AddHours(5), + FlightDate: departs); + + var result = await TrackedAsync(() => PostJsonAsync("/api/flights", command)); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _flightId = result.Body.Id; + } + + public Task WhenAllFlightsAreRead() => WhenActing("all flights are read", async _ => + { + var result = await GetJsonAsync>("/api/flights"); + _lastStatusCode = result.StatusCode; + _flights = result.Body ?? []; + }); + + public Task WhenTheFlightIsRead(Guid id) => WhenActing($"the flight with id {id} is read", async _ => + { + var result = await GetJsonAsync($"/api/flights/{id}"); + _lastStatusCode = result.StatusCode; + }); + + // ---- booking steps ---------------------------------------------------------------------- + + [Given("the flight was booked for the passenger")] + public Task GivenTheFlightWasBookedForThePassenger() => book(_passengerId, _flightId); + + public Task WhenTheFlightIsBookedForThePassenger() + => WhenActing("the flight is booked for the passenger", _ => book(_passengerId, _flightId)); + + // Fresh ids rather than Guid.Empty: an empty id is rejected by the command's validator before the + // endpoint runs, whereas an id nobody stored exercises the [Entity] OnMissing path the endpoint + // advertises -- a missing referenced entity is bad input, so 400 rather than 404. + public Task WhenTheFlightIsBookedForAnUnknownPassenger() + => WhenActing("the flight is booked for a passenger that does not exist", _ => book(Guid.NewGuid(), _flightId)); + + public Task WhenAnUnknownFlightIsBookedForThePassenger() + => WhenActing("a flight that does not exist is booked for the passenger", _ => book(_passengerId, Guid.NewGuid())); + + private async Task book(Guid passengerId, Guid flightId) + { + var result = await TrackedAsync(() => PostJsonAsync( + "/api/bookings", new CreateBooking(passengerId, flightId, Description: null))); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _bookingId = result.Body.Id; + } + + public Task WhenAllBookingsAreRead() => WhenActing("all bookings are read", async _ => + { + var result = await GetJsonAsync>("/api/bookings"); + _lastStatusCode = result.StatusCode; + _bookings = result.Body ?? []; + }); + + public Task WhenTheBookingIsRead() => WhenActing("the booking is read by id", async _ => + { + var result = await GetJsonAsync($"/api/bookings/{_bookingId}"); + _lastStatusCode = result.StatusCode; + }); + + // ---- assertions ------------------------------------------------------------------------- + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + [Then("the stored user has email \"{email}\"")] + public async Task ThenTheStoredUserHasEmail(string email) + => SpecAssert.Check("email", (await GetJsonAsync($"/api/identity/{_userId}")).Body?.Email, email); + + /// + /// The stub is written by the Passenger module's UserCreated handler, off a durable local queue, under + /// the user's own id -- so reading it back is the only way to observe that the cascade between the two + /// modules actually arrived. + /// + [Then("a passenger stub exists for that user")] + public async Task ThenAPassengerStubExistsForThatUser() + { + var result = await GetJsonAsync($"/api/passengers/{_userId}"); + SpecAssert.Check("status", result.StatusCode, 200); + SpecAssert.Check("id", result.Body?.Id, _userId); + } + + [Then("the stored passenger is named \"{name}\" aged {age}")] + public async Task ThenTheStoredPassengerIs(string name, int age) + { + var passenger = (await GetJsonAsync($"/api/passengers/{_passengerId}")).Body; + SpecAssert.Check("name", passenger?.Name, name); + SpecAssert.Check("age", passenger?.Age, age); + } + + [Then("the stored flight is {flightNumber} priced {price}")] + public async Task ThenTheStoredFlightIs(string flightNumber, decimal price) + { + var flight = (await GetJsonAsync($"/api/flights/{_flightId}")).Body; + SpecAssert.Check("flight number", flight?.FlightNumber, flightNumber); + SpecAssert.Check("price", flight?.Price, price); + } + + [Then("the flights include {flightNumber}")] + public void ThenTheFlightsInclude(string flightNumber) + => SpecAssert.Fact(_flights.Any(f => f.FlightNumber == flightNumber), + $"The flights are [{string.Join(", ", _flights.Select(f => f.FlightNumber))}], expected '{flightNumber}'."); + + /// + /// Reads the booking back over HTTP as well: the GET serves the inline snapshot, and that is what a + /// client sees. + /// + [Then("the stored booking is for \"{passengerName}\" on flight {flightNumber} priced {price}")] + public async Task ThenTheStoredBookingIs(string passengerName, string flightNumber, decimal price) + { + var booking = (await GetJsonAsync($"/api/bookings/{_bookingId}")).Body; + SpecAssert.Check("passenger", booking?.PassengerName, passengerName); + SpecAssert.Check("flight number", booking?.FlightNumber, flightNumber); + SpecAssert.Check("price", booking?.Price, price); + } + + [Then("the bookings include the new booking")] + public void ThenTheBookingsIncludeTheNewBooking() + => SpecAssert.Fact(_bookings.Any(b => b.Id == _bookingId), $"Booking {_bookingId} is not in the list."); +} diff --git a/src/Samples/Bobcat/BookingMonolith/Tests/Tests.csproj b/src/Samples/Bobcat/BookingMonolith/Tests/Tests.csproj new file mode 100644 index 000000000..fb0325f03 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + BookingMonolith.Tests + BookingMonolith.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/BookingMonolith/Tests/Usings.cs b/src/Samples/Bobcat/BookingMonolith/Tests/Usings.cs new file mode 100644 index 000000000..6602f579b --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/Tests/Usings.cs @@ -0,0 +1,4 @@ +global using Bobcat; +global using Bobcat.CritterStack; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/BookingMonolith/appsettings.json b/src/Samples/Bobcat/BookingMonolith/appsettings.json new file mode 100644 index 000000000..ac0da4bc1 --- /dev/null +++ b/src/Samples/Bobcat/BookingMonolith/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=booking;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/CleanArchitectureTodos.csproj b/src/Samples/Bobcat/CleanArchitectureTodos/CleanArchitectureTodos.csproj new file mode 100644 index 000000000..57d287b57 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/CleanArchitectureTodos.csproj @@ -0,0 +1,33 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/CleanArchitectureTodos.slnx b/src/Samples/Bobcat/CleanArchitectureTodos/CleanArchitectureTodos.slnx new file mode 100644 index 000000000..023903d8d --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/CleanArchitectureTodos.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/CreateTodoItemRequest.cs b/src/Samples/Bobcat/CleanArchitectureTodos/CreateTodoItemRequest.cs new file mode 100644 index 000000000..033ef6b0b --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/CreateTodoItemRequest.cs @@ -0,0 +1,43 @@ +using FluentValidation; +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace CleanArchitectureTodos; + +public record CreateTodoItemRequest(int ListId, string Title) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.ListId).GreaterThan(0); + } + } +} + +public static class CreateTodoItemEndpoint +{ + // [Entity] loads the TodoList by the "ListId" property on the request. + // + // Created rather than a bare TodoItem, so the response carries the 201 a newly-created + // resource should. Returned as a TypedResults value directly rather than as a + // (TodoItem, IResult) tuple — see docs/sample-wiring.md footgun 3. Items are nested in + // their list's document, so the Location points at the list that now contains it. + [WolverinePost("/api/todoitems")] + public static Created Post( + CreateTodoItemRequest request, + [Entity("ListId", Required = true)] TodoList list, + IDocumentSession session) + { + var item = new TodoItem { Title = request.Title }; + list.Items.Add(item); + list.LastModified = DateTimeOffset.UtcNow; + + session.Store(list); + + return TypedResults.Created($"/api/todolists/{list.Id}", item); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/CreateTodoListRequest.cs b/src/Samples/Bobcat/CleanArchitectureTodos/CreateTodoListRequest.cs new file mode 100644 index 000000000..fbaf62470 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/CreateTodoListRequest.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; + +namespace CleanArchitectureTodos; + +public record CreateTodoListRequest(string Title, string? Colour) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + } + } +} + +public static class CreateTodoListEndpoint +{ + // Sad-path validation pulled out of the main handler. + // Wolverine calls this before Post() — if it returns a populated ProblemDetails, + // the Post() method is never invoked. See: Railway Programming skill. + public static async Task ValidateAsync( + CreateTodoListRequest request, + IQuerySession session) + { + var exists = await session.Query().AnyAsync(l => l.Title == request.Title); + if (exists) + return new ProblemDetails + { + Detail = $"'{request.Title}' already exists.", + Status = 400, + }; + + return WolverineContinue.NoProblems; + } + + // The main handler is synchronous and focused purely on the happy path. + // FluentValidation middleware handles structural validation (empty/length). + // ValidateAsync above handles business rule validation (uniqueness). + // + // Created rather than a bare TodoList, so the response carries the 201 and Location a + // newly-created resource should. Returned as a TypedResults value directly — a + // (TodoList, IResult) tuple would be read by Wolverine.HTTP as (body, cascaded-message) + // and the IResult dispatched as a message with no handler. See docs/sample-wiring.md + // footgun 3. Marten assigns the HiLo id on Store, so it is available for the Location. + [WolverinePost("/api/todolists")] + public static Created Post(CreateTodoListRequest request, IDocumentSession session) + { + var list = new TodoList + { + Title = request.Title, + Colour = request.Colour ?? "#808080", + Created = DateTimeOffset.UtcNow, + }; + + session.Store(list); + + return TypedResults.Created($"/api/todolists/{list.Id}", list); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/DeleteTodoItem.cs b/src/Samples/Bobcat/CleanArchitectureTodos/DeleteTodoItem.cs new file mode 100644 index 000000000..61697e46c --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/DeleteTodoItem.cs @@ -0,0 +1,25 @@ +using Marten; +using Wolverine.Http; + +namespace CleanArchitectureTodos; + +public static class DeleteTodoItemEndpoint +{ + // Items are nested — must query parent list by child Guid + [WolverineDelete("/api/todoitems/{id}")] + public static async Task Delete(Guid id, IDocumentSession session, CancellationToken ct) + { + var list = await session.Query() + .Where(l => l.Items.Any(i => i.Id == id)) + .FirstOrDefaultAsync(ct); + + if (list is null) return Results.NotFound(); + + list.Items.Remove(list.Items.First(i => i.Id == id)); + list.LastModified = DateTimeOffset.UtcNow; + + session.Store(list); + + return Results.NoContent(); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/DeleteTodoList.cs b/src/Samples/Bobcat/CleanArchitectureTodos/DeleteTodoList.cs new file mode 100644 index 000000000..72bd6da81 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/DeleteTodoList.cs @@ -0,0 +1,14 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace CleanArchitectureTodos; + +public static class DeleteTodoListEndpoint +{ + [WolverineDelete("/api/todolists/{id}")] + public static void Delete(int id, [Entity(Required = true)] TodoList list, IDocumentSession session) + { + session.Delete(list); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/Dtos.cs b/src/Samples/Bobcat/CleanArchitectureTodos/Dtos.cs new file mode 100644 index 000000000..61037e56f --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/Dtos.cs @@ -0,0 +1,8 @@ +namespace CleanArchitectureTodos; + +// Shared response DTOs +public record TodoListDto(int Id, string Title, string Colour, IReadOnlyList Items); +public record TodoItemDto(Guid Id, string Title, string? Note, int Priority, bool Done); +public record TodosVm(IReadOnlyList PriorityLevels, IReadOnlyList Colours, IReadOnlyList Lists); +public record LookupDto(int Id, string Title); +public record ColourDto(string Code, string Name); diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/GetAllTodoLists.cs b/src/Samples/Bobcat/CleanArchitectureTodos/GetAllTodoLists.cs new file mode 100644 index 000000000..d3f407181 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/GetAllTodoLists.cs @@ -0,0 +1,32 @@ +using Marten; +using Wolverine.Http; + +namespace CleanArchitectureTodos; + +public static class GetAllTodoListsEndpoint +{ + [WolverineGet("/api/todolists")] + public static async Task Get(IQuerySession session, CancellationToken ct) + { + var lists = await session.Query() + .OrderBy(l => l.Title) + .ToListAsync(ct); + + var priorityLevels = Enum.GetValues() + .Select(p => new LookupDto((int)p, p.ToString())) + .ToList(); + + var colours = TodoColours.Supported + .Select(c => new ColourDto(c.Key, c.Value)) + .ToList(); + + var listDtos = lists.Select(l => new TodoListDto( + l.Id, + l.Title, + l.Colour, + l.Items.Select(i => new TodoItemDto(i.Id, i.Title, i.Note, (int)i.Priority, i.Done)).ToList() + )).ToList(); + + return new TodosVm(priorityLevels, colours, listDtos); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/GetTodoList.cs b/src/Samples/Bobcat/CleanArchitectureTodos/GetTodoList.cs new file mode 100644 index 000000000..20a500f74 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/GetTodoList.cs @@ -0,0 +1,15 @@ +using Wolverine.Http; +using Wolverine.Persistence; + +namespace CleanArchitectureTodos; + +public static class GetTodoListEndpoint +{ + // The one read endpoint this sample was missing. Without it a list could only be written + // to or found in the unsorted bulk of GET /api/todolists, so the specs' central claims — + // a new list gets the default colour, an item update actually changed the stored item — + // were unobservable. [Entity] answers 404 for an id that does not exist. + // See docs/sample-wiring.md footgun 9. + [WolverineGet("/api/todolists/{id}")] + public static TodoList Get([Entity(Required = true)] TodoList list) => list; +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/Program.cs b/src/Samples/Bobcat/CleanArchitectureTodos/Program.cs new file mode 100644 index 000000000..91b115cfe --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/Program.cs @@ -0,0 +1,47 @@ +using Marten; +using Wolverine; +using Wolverine.FluentValidation; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=clean_architecture_todos;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "todos"; + opts.Schema.For().Index(x => x.Title); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +await app.RunAsync(); diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/README.md b/src/Samples/Bobcat/CleanArchitectureTodos/README.md new file mode 100644 index 000000000..77a954349 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/README.md @@ -0,0 +1,65 @@ +# Clean Architecture Todos — Critter Stack Conversion + +## Original Project + +**Repository:** [jasontaylordev/CleanArchitecture](https://github.com/jasontaylordev/CleanArchitecture) +**License:** MIT +**Stack:** ASP.NET Core 10, MediatR, FluentValidation, AutoMapper, EF Core, ASP.NET Identity + +The most-starred Clean Architecture template in the .NET ecosystem (~16k stars). A Todo application organized across 5 projects (Domain, Application, Infrastructure, Web, Tests) with MediatR pipeline behaviors for validation, logging, authorization, and performance monitoring. + +## What Changed + +### Removed (5 projects collapsed to 1) +- **Domain project** — 12 files (base classes, value objects, domain events, enums) +- **Application project** — 28 files (commands, queries, handlers, validators, behaviors, DTOs, AutoMapper profiles) +- **Infrastructure project** — 11 files (DbContext, configurations, interceptors, identity) +- **MediatR** — 7 commands, 2 queries, 9 handlers, 5 pipeline behaviors +- **FluentValidation** — 4 validator classes +- **AutoMapper** — mapping profiles and DTOs +- **EF Core** — DbContext, entity configurations, migrations, interceptors +- **ASP.NET Identity** — ApplicationUser, IdentityService + +### Added +- **Wolverine.Http** — `[WolverineGet]`, `[WolverinePost]`, etc. +- **Marten** — PostgreSQL document store + +### Before vs After + +| Aspect | Original (Clean Architecture) | Converted (Wolverine + Marten) | +|--------|------------------------------|-------------------------------| +| Projects | 5 (Domain, Application, Infrastructure, Web, Tests) | 1 | +| C# files | ~67 | 5 | +| NuGet packages | MediatR, FluentValidation, AutoMapper, EF Core, Identity | WolverineFx.Http, Marten | +| Pipeline behaviors | 5 classes (Logging, Validation, Authorization, Performance, Exception) | Inline in endpoints | +| Per-feature artifacts | ~5 files (command, validator, handler, DTO, mapper) | 1 endpoint method | +| Database | EF Core (SQLite/SQL Server/PostgreSQL) | Marten (PostgreSQL document store) | + +### The "Unraveling" Story + +In the original, creating a TodoList required: +1. `CreateTodoListCommand.cs` — the command record +2. `CreateTodoListCommandValidator.cs` — FluentValidation rules +3. `CreateTodoListCommandHandler.cs` — the handler (7 lines of actual logic) +4. `TodoListDto.cs` + AutoMapper profile — response mapping +5. Pipeline behavior registrations in `DependencyInjection.cs` + +In the converted version, the same operation is a single static method in `TodoListEndpoints.cs` with inline validation — about 20 lines total including the validation. + +### Architecture Decisions + +**Document model instead of relational:** TodoList contains its Items as a nested collection in a single Marten document, eliminating the need for a separate TodoItem table, foreign keys, and navigation property configuration. This matches how the data is actually consumed (always as a list with its items). + +**Inline validation instead of pipeline behaviors:** For a simple CRUD app, the MediatR pipeline behavior chain (logging → exception handling → authorization → validation → performance → handler) adds significant indirection. Wolverine supports middleware, but for this sample the validation is simple enough to inline. + +**No separate DTO layer:** The endpoint methods return response records defined alongside the endpoint. AutoMapper configuration is replaced by a LINQ `Select` projection. + +## Running + +Requires PostgreSQL. Update the connection string in `appsettings.json`, then: + +```bash +dotnet run +``` + +Swagger UI available at `/swagger`. diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..af5fad799 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/AssemblyAttributes.cs @@ -0,0 +1,24 @@ +using CleanArchitectureTodos.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(CleanArchitectureTodosHost))] + +// Every spec empties the store before it runs (see CleanArchitectureTodosSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tells ASP.NET Core's WebApplicationFactory (which Alba uses) where the host's content root +// is. Without it, discovery walks up from the test assembly's bin directory and synthesizes +// `samples/CleanArchitectureTodos/CleanArchitectureTodos/` from the host assembly name — a +// folder that does not exist — and throws DirectoryNotFoundException. The Tests project lives +// INSIDE the host project rather than beside it, which is not the layout the default assumes. +// +// The path is relative to the test assembly's bin directory at run time: from +// `samples/CleanArchitectureTodos/Tests/bin//net10.0/`, four levels up is +// `samples/CleanArchitectureTodos/`, where the host's appsettings.json lives. +[assembly: WebApplicationFactoryContentRoot( + "CleanArchitectureTodos", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/Tests/CleanArchitectureTodosHost.cs b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/CleanArchitectureTodosHost.cs new file mode 100644 index 000000000..4925f4da9 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/CleanArchitectureTodosHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace CleanArchitectureTodos.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class CleanArchitectureTodosHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/Tests/CleanArchitectureTodosSpec.cs b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/CleanArchitectureTodosSpec.cs new file mode 100644 index 000000000..615af0067 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/CleanArchitectureTodosSpec.cs @@ -0,0 +1,52 @@ +using Alba; +using Bobcat.CritterStack; + +namespace CleanArchitectureTodos.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every CleanArchitectureTodos spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// The status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a spec +/// asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +public abstract class CleanArchitectureTodosSpec(CleanArchitectureTodosHost app) : IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // Load-bearing: todo list titles are unique by business rule, and every spec creates its lists under a fixed title. Without the reset the suite passes once per database and then answers 400 for lists it believes are new, and "2 lists are returned" counts lists earlier runs left behind. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/Tests/Tests.csproj b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/Tests.csproj new file mode 100644 index 000000000..4d77d6d90 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + CleanArchitectureTodos.Tests + CleanArchitectureTodos.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/Tests/TodoSpecs.cs b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/TodoSpecs.cs new file mode 100644 index 000000000..f8b3ef63e --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/TodoSpecs.cs @@ -0,0 +1,263 @@ +namespace CleanArchitectureTodos.Tests; + +/// +/// The todo list and todo item endpoints, driven over HTTP. Reuses the host's own request, document and +/// view-model types via the project reference rather than re-declaring them locally. +/// +/// +/// Every "stored list" assertion reads the list back over HTTP rather than trusting what the write +/// echoed, so a write that answered the right status but never reached Marten still fails. +/// +[BobcatFeature("Clean Architecture Todos")] +public class TodoSpecs(CleanArchitectureTodosHost app) : CleanArchitectureTodosSpec(app) +{ + // The first list a spec created -- the one the unqualified steps ("the stored list", "the todo list + // is deleted") refer to. Lists created later in the same spec are reached by title. + private int _listId; + private readonly Dictionary _listIdsByTitle = new(); + private Guid _itemId; + private int _lastStatusCode; + private IReadOnlyList _lists = []; + + // ---- lists ------------------------------------------------------------------------------ + + [BobcatSpec] + public async Task create_a_todo_list() + { + await WhenATodoListIsCreated("My List"); + + ThenTheResponseStatusIs(201); + await ThenTheStoredListIsTitled("My List"); + } + + [BobcatSpec] + public async Task new_todo_list_has_default_colour() + { + await WhenATodoListIsCreated("Colour List"); + + await ThenTheStoredListHasTheColour("#808080"); + } + + [BobcatSpec] + public async Task create_duplicate_title_returns_400() + { + await GivenATodoList("Duplicate"); + + await WhenATodoListIsCreated("Duplicate"); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task update_a_todo_list() + { + await GivenATodoList("Old Title"); + + await WhenTheListIsRetitled("New Title"); + + ThenTheResponseStatusIs(200); + await ThenTheStoredListIsTitled("New Title"); + } + + [BobcatSpec] + public async Task update_to_duplicate_title_returns_400() + { + await GivenATodoList("First"); + await GivenATodoList("Second"); + + await WhenTheNamedListIsRetitled("Second", "First"); + + ThenTheResponseStatusIs(400); + await ThenTheStoredListIsStillTitled("Second", "Second"); + } + + [BobcatSpec] + public async Task delete_a_todo_list() + { + await GivenATodoList("To Delete"); + + await WhenTheTodoListIsDeleted(); + + ThenTheResponseStatusIs(204); + await ThenTheListNoLongerExists(); + } + + [BobcatSpec] + public async Task get_all_todo_lists() + { + await GivenATodoList("List A"); + await GivenATodoList("List B"); + + await WhenAllTodoListsAreRead(); + + ThenListsAreReturned(2); + } + + // ---- items ------------------------------------------------------------------------------ + + [BobcatSpec] + public async Task create_a_todo_item() + { + await GivenATodoList("Item List"); + + await WhenATodoItemIsCreated("Do something"); + + ThenTheResponseStatusIs(201); + await ThenTheStoredListHasAnItemTitled("Do something"); + } + + [BobcatSpec] + public async Task update_a_todo_item() + { + await GivenATodoList("Update Item List"); + await GivenATodoItem("Original"); + + await WhenTheTodoItemIsRetitled("Updated"); + + ThenTheResponseStatusIs(204); + await ThenTheStoredListHasAnItemTitled("Updated"); + } + + [BobcatSpec] + public async Task delete_a_todo_item() + { + await GivenATodoList("Delete Item List"); + await GivenATodoItem("To Delete"); + + await WhenTheTodoItemIsDeleted(); + + ThenTheResponseStatusIs(204); + await ThenTheStoredListHasItems(0); + } + + // ---- list steps ------------------------------------------------------------------------- + + [Given("a todo list titled \"{title}\"")] + public Task GivenATodoList(string title) => createTodoList(title); + + [When("a todo list titled \"{title}\" is created")] + public Task WhenATodoListIsCreated(string title) => createTodoList(title); + + private async Task createTodoList(string title) + { + // Colour is null so the spec about the default colour exercises the host's default, not one + // the spec supplied. + var result = await PostJsonAsync( + "/api/todolists", new CreateTodoListRequest(title, Colour: null)); + + _lastStatusCode = result.StatusCode; + + // Only take the id from a success. A 400 ProblemDetails body deserializes into a TodoList just + // fine (unknown properties are ignored), so a body is not evidence that anything was created. + if (result.StatusCode is >= 200 and < 300 && result.Body is not null) + { + if (_listId == 0) _listId = result.Body.Id; + _listIdsByTitle[title] = result.Body.Id; + } + } + + [When("the list is retitled \"{newTitle}\"")] + public Task WhenTheListIsRetitled(string newTitle) => retitleList(_listId, newTitle); + + [When("the list \"{title}\" is retitled \"{newTitle}\"")] + public Task WhenTheNamedListIsRetitled(string title, string newTitle) => retitleList(_listIdsByTitle[title], newTitle); + + private async Task retitleList(int listId, string newTitle) + { + // PUT, and the request carries a Colour as well as the title; null leaves the stored colour alone. + var result = await PutJsonAsync( + $"/api/todolists/{listId}", new UpdateTodoListRequest(newTitle, Colour: null)); + + _lastStatusCode = result.StatusCode; + } + + [When("the todo list is deleted")] + public async Task WhenTheTodoListIsDeleted() + { + var result = await DeleteAsync($"/api/todolists/{_listId}"); + _lastStatusCode = result.StatusCode; + } + + [When("all todo lists are read")] + public async Task WhenAllTodoListsAreRead() + { + // The GET returns a view model wrapping the lists alongside the priority and colour lookups. + var result = await GetJsonAsync("/api/todolists"); + _lastStatusCode = result.StatusCode; + _lists = result.Body?.Lists ?? []; + } + + // ---- item steps ------------------------------------------------------------------------- + + [Given("a todo item titled \"{title}\"")] + public Task GivenATodoItem(string title) => createTodoItem(title); + + [When("a todo item titled \"{title}\" is created")] + public Task WhenATodoItemIsCreated(string title) => createTodoItem(title); + + private async Task createTodoItem(string title) + { + var result = await PostJsonAsync( + "/api/todoitems", new CreateTodoItemRequest(_listId, title)); + + _lastStatusCode = result.StatusCode; + + // TodoItem.Id is initialised to Guid.NewGuid(), so a ProblemDetails body read as a TodoItem + // carries a plausible id for an item that was never created. + if (result.StatusCode is >= 200 and < 300 && result.Body is not null) _itemId = result.Body.Id; + } + + [When("the todo item is retitled \"{newTitle}\"")] + public async Task WhenTheTodoItemIsRetitled(string newTitle) + { + // The item id travels in the route; the request is (Title, Done). + var result = await PutJsonAsync( + $"/api/todoitems/{_itemId}", new UpdateTodoItemRequest(newTitle, Done: false)); + + _lastStatusCode = result.StatusCode; + } + + [When("the todo item is deleted")] + public async Task WhenTheTodoItemIsDeleted() + { + var result = await DeleteAsync($"/api/todoitems/{_itemId}"); + _lastStatusCode = result.StatusCode; + } + + // ---- assertions ------------------------------------------------------------------------- + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + private Task> loadList(int listId) => GetJsonAsync($"/api/todolists/{listId}"); + + [Then("the stored list is titled \"{title}\"")] + public async Task ThenTheStoredListIsTitled(string title) + => SpecAssert.Check("title", (await loadList(_listId)).Body?.Title, title); + + [Then("the stored list \"{createdAs}\" is still titled \"{title}\"")] + public async Task ThenTheStoredListIsStillTitled(string createdAs, string title) + => SpecAssert.Check("title", (await loadList(_listIdsByTitle[createdAs])).Body?.Title, title); + + [Then("the stored list has the colour \"{colour}\"")] + public async Task ThenTheStoredListHasTheColour(string colour) + => SpecAssert.Check("colour", (await loadList(_listId)).Body?.Colour, colour); + + [Then("the list no longer exists")] + public async Task ThenTheListNoLongerExists() + => SpecAssert.Check("status", (await loadList(_listId)).StatusCode, 404); + + [Then("{count} lists are returned")] + public void ThenListsAreReturned(int count) => SpecAssert.Check("count", _lists.Count, count); + + [Then("the stored list has an item titled \"{title}\"")] + public async Task ThenTheStoredListHasAnItemTitled(string title) + { + var items = (await loadList(_listId)).Body?.Items.Select(i => i.Title).ToList() ?? []; + SpecAssert.Fact(items.Contains(title), $"The list's items are [{string.Join(", ", items)}], expected one titled '{title}'."); + } + + [Then("the stored list has {count} items")] + public async Task ThenTheStoredListHasItems(int count) + => SpecAssert.Check("count", (await loadList(_listId)).Body?.Items.Count, count); +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/Tests/Usings.cs b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/Usings.cs new file mode 100644 index 000000000..c17dbd16f --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/Tests/Usings.cs @@ -0,0 +1,3 @@ +global using Bobcat; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/TodoList.cs b/src/Samples/Bobcat/CleanArchitectureTodos/TodoList.cs new file mode 100644 index 000000000..3e952a499 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/TodoList.cs @@ -0,0 +1,55 @@ +namespace CleanArchitectureTodos; + +/// +/// TodoList aggregate stored as a Marten document. +/// Contains its own items — no separate TodoItem table needed. +/// +public class TodoList +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Colour { get; set; } = "#808080"; // Grey default + public List Items { get; set; } = []; + + // Audit fields + public DateTimeOffset Created { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? LastModified { get; set; } + public string? LastModifiedBy { get; set; } +} + +public class TodoItem +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = string.Empty; + public string? Note { get; set; } + public PriorityLevel Priority { get; set; } = PriorityLevel.None; + public bool Done { get; set; } +} + +public enum PriorityLevel +{ + None = 0, + Low = 1, + Medium = 2, + High = 3, +} + +/// +/// Supported colours for todo lists (replaces the Colour value object). +/// +public static class TodoColours +{ + public static readonly Dictionary Supported = new() + { + ["#FF0000"] = "Red", + ["#FFA500"] = "Orange", + ["#008000"] = "Green", + ["#008080"] = "Teal", + ["#0000FF"] = "Blue", + ["#800080"] = "Purple", + ["#808080"] = "Grey", + }; + + public static bool IsValid(string code) => Supported.ContainsKey(code); +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoItemDetailRequest.cs b/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoItemDetailRequest.cs new file mode 100644 index 000000000..22a78f399 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoItemDetailRequest.cs @@ -0,0 +1,45 @@ +using Marten; +using Wolverine.Http; + +namespace CleanArchitectureTodos; + +public record UpdateTodoItemDetailRequest(int ListId, PriorityLevel Priority, string? Note); + +public static class UpdateTodoItemDetailEndpoint +{ + // IResult justified: may move item between lists (variable logic paths) + [WolverinePatch("/api/todoitems/detail/{id}")] + public static async Task Patch(Guid id, UpdateTodoItemDetailRequest request, IDocumentSession session, CancellationToken ct) + { + var currentList = await session.Query() + .Where(l => l.Items.Any(i => i.Id == id)) + .FirstOrDefaultAsync(ct); + + if (currentList is null) return Results.NotFound(); + + var item = currentList.Items.First(i => i.Id == id); + item.Priority = request.Priority; + item.Note = request.Note; + + if (request.ListId != currentList.Id) + { + var targetList = await session.LoadAsync(request.ListId, ct); + if (targetList is null) return Results.NotFound(); + + currentList.Items.Remove(item); + targetList.Items.Add(item); + currentList.LastModified = DateTimeOffset.UtcNow; + targetList.LastModified = DateTimeOffset.UtcNow; + + session.Store(currentList); + session.Store(targetList); + } + else + { + currentList.LastModified = DateTimeOffset.UtcNow; + session.Store(currentList); + } + + return Results.NoContent(); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoItemRequest.cs b/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoItemRequest.cs new file mode 100644 index 000000000..237bafab7 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoItemRequest.cs @@ -0,0 +1,46 @@ +using FluentValidation; +using Marten; +using Wolverine.Http; + +namespace CleanArchitectureTodos; + +public record UpdateTodoItemRequest(string Title, bool Done) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + } + } +} + +public static class UpdateTodoItemEndpoint +{ + // Items are nested in TodoList documents — must query by child Guid. + // IResult justified: need to search across lists for the nested item. + [WolverinePut("/api/todoitems/{id}")] + public static async Task Put(Guid id, UpdateTodoItemRequest request, IDocumentSession session, CancellationToken ct) + { + var list = await session.Query() + .Where(l => l.Items.Any(i => i.Id == id)) + .FirstOrDefaultAsync(ct); + + if (list is null) return Results.NotFound(); + + var item = list.Items.First(i => i.Id == id); + var wasDone = item.Done; + item.Title = request.Title; + item.Done = request.Done; + list.LastModified = DateTimeOffset.UtcNow; + + session.Store(list); + + if (!wasDone && item.Done) + { + Console.WriteLine($"[TodoItemCompleted] Item '{item.Title}' in list '{list.Title}' was marked as done."); + } + + return Results.NoContent(); + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoListRequest.cs b/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoListRequest.cs new file mode 100644 index 000000000..a51176f92 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/UpdateTodoListRequest.cs @@ -0,0 +1,52 @@ +using FluentValidation; +using Marten; +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace CleanArchitectureTodos; + +public record UpdateTodoListRequest(string Title, string? Colour) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + } + } +} + +public static class UpdateTodoListEndpoint +{ + public static async Task ValidateAsync( + int id, + UpdateTodoListRequest request, + IQuerySession session) + { + var duplicate = await session.Query().AnyAsync(l => l.Title == request.Title && l.Id != id); + if (duplicate) + return new ProblemDetails + { + Detail = $"'{request.Title}' already exists.", + Status = 400, + }; + + return WolverineContinue.NoProblems; + } + + [WolverinePut("/api/todolists/{id}")] + public static TodoList Put( + UpdateTodoListRequest request, + [Entity(Required = true)] TodoList list, + IDocumentSession session) + { + list.Title = request.Title; + list.Colour = request.Colour ?? list.Colour; + list.LastModified = DateTimeOffset.UtcNow; + + session.Store(list); + + return list; + } +} diff --git a/src/Samples/Bobcat/CleanArchitectureTodos/appsettings.json b/src/Samples/Bobcat/CleanArchitectureTodos/appsettings.json new file mode 100644 index 000000000..f5ca30af4 --- /dev/null +++ b/src/Samples/Bobcat/CleanArchitectureTodos/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=clean_architecture_todos;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/CqrsMinimalApi/CqrsMinimalApi.csproj b/src/Samples/Bobcat/CqrsMinimalApi/CqrsMinimalApi.csproj new file mode 100644 index 000000000..16de44742 --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/CqrsMinimalApi.csproj @@ -0,0 +1,31 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/CqrsMinimalApi/CqrsMinimalApi.slnx b/src/Samples/Bobcat/CqrsMinimalApi/CqrsMinimalApi.slnx new file mode 100644 index 000000000..23fdd65bf --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/CqrsMinimalApi.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Samples/Bobcat/CqrsMinimalApi/Program.cs b/src/Samples/Bobcat/CqrsMinimalApi/Program.cs new file mode 100644 index 000000000..a7a25c1b8 --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/Program.cs @@ -0,0 +1,68 @@ +using Marten; +using Wolverine; +using Wolverine.Http; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=cqrs_minimal_api;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "cqrs_demo"; + + // Student is Guid-keyed (the RESTful API expects Guid ids per the spec); + // Marten's default Guid identity is fine here. UseNumericRevisions / HiLo + // were dropped along with the old int-based contract. + opts.Schema.For().Index(x => x.Name); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(); + +// Seed initial data +await SeedData(app); + +await app.RunAsync(); + +static async Task SeedData(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + var existing = await session.Query().AnyAsync(); + if (!existing) + { + // Explicit ids so the seeded set is reproducible and stable across restarts. + session.Store( + new CqrsMinimalApi.Student { Id = Guid.NewGuid(), Name = "Tonny Blatt", Address = "123 Main St", Email = "tonny@example.com", DateOfBirth = new DateTime(1991, 10, 7) }, + new CqrsMinimalApi.Student { Id = Guid.NewGuid(), Name = "Anitta Goldman", Address = "456 Oak Ave", Email = "anitta@example.com", DateOfBirth = new DateTime(1975, 5, 31) }, + new CqrsMinimalApi.Student { Id = Guid.NewGuid(), Name = "Alan Ford", Address = "789 Pine Rd", Email = "alan@example.com", DateOfBirth = new DateTime(2000, 8, 26) }, + new CqrsMinimalApi.Student { Id = Guid.NewGuid(), Name = "Jim Beam", Address = "321 Elm St", Email = "jim@example.com", DateOfBirth = new DateTime(1984, 1, 12) }, + new CqrsMinimalApi.Student { Id = Guid.NewGuid(), Name = "Suzanne White", Address = "654 Birch Ln", Email = "suzanne@example.com", DateOfBirth = new DateTime(1992, 3, 10) } + ); + await session.SaveChangesAsync(); + } +} diff --git a/src/Samples/Bobcat/CqrsMinimalApi/README.md b/src/Samples/Bobcat/CqrsMinimalApi/README.md new file mode 100644 index 000000000..492dceab8 --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/README.md @@ -0,0 +1,93 @@ +# CQRS Minimal API — Critter Stack Conversion + +## Original Project + +**Repository:** [matjazbravc/CQRS.MinimalAPI.Demo](https://github.com/matjazbravc/CQRS.MinimalAPI.Demo) +**License:** MIT +**Stack:** ASP.NET Core 8 Minimal API, MediatR 12, EF Core 8 (SQLite), MiniValidation + +A CQRS demonstration using MediatR for command/query dispatch with a Student management domain. Shows the typical MediatR + Minimal API wiring pattern: endpoints call a service layer, which creates MediatR commands/queries and sends them through the mediator. + +## What Changed + +### Removed (6 layers of indirection) +- **MediatR** — 3 command classes, 3 query classes, 6 handler classes +- **Service layer** — `IStudentsService` / `StudentsService` mediator bridge +- **Repository layer** — `IBaseRepository`, `BaseRepository`, `IStudentsRepository`, `StudentsRepository` +- **EF Core** — `DataContext`, SQLite database, migrations +- **MiniValidation** — replaced by standard ASP.NET model binding + +### Added +- **Wolverine.Http** — `[WolverineGet]`, `[WolverinePost]`, etc. endpoint attributes +- **Marten** — PostgreSQL document store, `IDocumentSession` / `IQuerySession` + +### Before vs After + +| Aspect | Original (MediatR) | Converted (Wolverine + Marten) | +|--------|-------------------|-------------------------------| +| Files | 22 C# files across 8 directories | 3 C# files in 1 directory | +| Request flow | Endpoint → Service → MediatR → Handler → Repository → EF Core | Endpoint → Marten session | +| Database | SQLite via EF Core + migrations | PostgreSQL via Marten (auto-schema) | +| Packages | MediatR, EF Core, MiniValidation | WolverineFx.Http, Marten | +| Identity type | `int` (auto-increment) | `int` (Marten HiLo) | +| Tests | None | Bobcat specifications (xUnit v3 + Alba) | + +### Architecture + +The original project demonstrated a 6-layer call chain for every operation: + +``` +Endpoint → IStudentsService → IMediator.Send() → IRequestHandler → IStudentsRepository → DbContext +``` + +The converted project collapses this to a single Wolverine HTTP endpoint method that directly uses Marten's document session: + +``` +[WolverinePost] endpoint method → IDocumentSession +``` + +Each endpoint is a static method in `StudentEndpoints.cs` with Wolverine HTTP attributes. Marten's `IDocumentSession` and `IQuerySession` are injected directly — no repository abstraction needed since Marten already provides a clean document store API. + +### Bug Fix + +The original `StudentsService.Create()` had Address and Email swapped in the command mapping. This was corrected in the conversion. + +## Running + +Requires PostgreSQL. Update the connection string in `appsettings.json`, then: + +```bash +dotnet run +``` + +Swagger UI available at `/swagger`. + +## Specifications + +The specs in `Tests/` are Bobcat's projected (code-first) model: ordinary xUnit v3 tests that render as +Given/When/Then specifications. There is no `.feature` file anymore. `StudentSpecs` covers the five +scenarios -- create, list in name order, a 404 for a missing id, update, and delete-then-404 -- and every +step is a method on that class with a `[Given]`, `[When]` or `[Then]` attribute, so calling it records it. + +```csharp +[BobcatSpec] +public async Task delete_student_and_verify_404() +{ + await GivenAStudent("Carol White", "carol@school.com"); + + await WhenTheStudentIsDeleted(); + ThenTheResponseStatusIs(204); + + await WhenTheStudentIsRead(_studentId); + ThenTheResponseStatusIs(404); +} +``` + +The host is booted once through Alba (`CqrsMinimalApiHost`), and `CqrsMinimalApiSpec` empties the store +before every spec -- the host seeds five students at start-up, and "at least 2 students are returned" +should mean the two the spec created. + +```bash +dotnet test --project Tests # the specs +BOBCAT_SPEC_CONSOLE=1 dotnet run --project Tests # ...and print every spec as it rendered +``` diff --git a/src/Samples/Bobcat/CqrsMinimalApi/Student.cs b/src/Samples/Bobcat/CqrsMinimalApi/Student.cs new file mode 100644 index 000000000..63bb7adf2 --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/Student.cs @@ -0,0 +1,16 @@ +namespace CqrsMinimalApi; + +/// +/// Student document stored in Marten. Guid-keyed for the RESTful API +/// (POST returns 201 with the assigned id; collection routes are +/// `/api/students/{id}`). +/// +public class Student +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Address { get; set; } + public string? Email { get; set; } + public DateTime? DateOfBirth { get; set; } + public bool Active { get; set; } = true; +} diff --git a/src/Samples/Bobcat/CqrsMinimalApi/StudentEndpoints.cs b/src/Samples/Bobcat/CqrsMinimalApi/StudentEndpoints.cs new file mode 100644 index 000000000..379e8b88d --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/StudentEndpoints.cs @@ -0,0 +1,109 @@ +using Marten; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace CqrsMinimalApi; + +// --- Request/Response records --- + +public record CreateStudentRequest(string Name, string? Email, string? Address = null, DateTime? DateOfBirth = null, bool Active = true); +public record CreateStudentResponse(Guid Id); +public record UpdateStudentRequest(string Name, string? Email = null, string? Address = null, DateTime? DateOfBirth = null, bool Active = true); + +// --- RESTful Wolverine HTTP endpoints --- +// +// URL shape (`/api/students`, `/api/students/{id}`) and Guid keys are the +// contract the Bobcat spec describes. Status codes follow REST conventions: +// POST /api/students -> 201 Created with { Id } +// GET /api/students -> 200 OK with array +// GET /api/students/{id} -> 200 OK or 404 +// PUT /api/students/{id} -> 200 OK or 404 +// POST /api/students/{id} -> 200 OK or 404 (compatibility alias for clients +// that don't issue PUT) +// DELETE /api/students/{id} -> 204 No Content or 404 +public static class StudentEndpoints +{ + [WolverinePost("/api/students")] + public static Created Create( + CreateStudentRequest request, + IDocumentSession session) + { + var student = new Student + { + Id = Guid.NewGuid(), + Name = request.Name, + Address = request.Address, + Email = request.Email, + DateOfBirth = request.DateOfBirth, + Active = request.Active + }; + + session.Store(student); + + // `Created` from Microsoft.AspNetCore.Http.HttpResults is a strong- + // typed result that carries both the 201 status AND the JSON body, so + // Wolverine.HTTP doesn't have to choose between "this is a cascaded + // message" and "this is the response". (An earlier + // (CreateStudentResponse, IResult) tuple return was misinterpreted as + // body+message, which surfaced as "No routes can be determined for + // Envelope ... Created".) + var response = new CreateStudentResponse(student.Id); + return TypedResults.Created($"/api/students/{student.Id}", response); + } + + [WolverineGet("/api/students")] + public static async Task> GetAll( + IQuerySession session, + CancellationToken ct) + { + return await session.Query() + .OrderBy(s => s.Name) + .ToListAsync(ct); + } + + // [Entity(Required = true)] makes Wolverine.HTTP return 404 automatically + // when the document isn't present, with no extra branching here. + [WolverineGet("/api/students/{id}")] + public static Student GetById(Guid id, [Entity(Required = true)] Student student) => student; + + [WolverinePut("/api/students/{id}")] + public static Student Update( + UpdateStudentRequest request, + Guid id, + [Entity(Required = true)] Student student, + IDocumentSession session) + { + student.Name = request.Name; + student.Address = request.Address; + student.Email = request.Email; + student.DateOfBirth = request.DateOfBirth; + student.Active = request.Active; + + session.Store(student); + return student; + } + + // POST alias for Update — accepts the same body and route as PUT so + // clients that prefer POST (or ones that route through proxies that strip + // PUT) can still update. Both verbs share the implementation. + [WolverinePost("/api/students/{id}")] + public static Student UpdateViaPost( + UpdateStudentRequest request, + Guid id, + [Entity(Required = true)] Student student, + IDocumentSession session) => Update(request, id, student, session); + + [WolverineDelete("/api/students/{id}")] + public static NoContent Delete( + Guid id, + [Entity(Required = true)] Student student, + IDocumentSession session) + { + session.Delete(student); + // Wolverine's transactional middleware persists the delete; we just + // need to advertise 204 No Content per REST convention. + return TypedResults.NoContent(); + } +} diff --git a/src/Samples/Bobcat/CqrsMinimalApi/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/CqrsMinimalApi/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..a03c73501 --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/Tests/AssemblyAttributes.cs @@ -0,0 +1,27 @@ +using CqrsMinimalApi.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(CqrsMinimalApiHost))] + +// Every spec empties the store before it runs (see CqrsMinimalApiSpec), so two specs running at +// once would wipe each other's students. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tell ASP.NET Core's WebApplicationFactory (used by Alba) where to find the +// host project's content root. Without this, Alba walks up from the test +// assembly's bin directory and tries `samples/CqrsMinimalApi/CqrsMinimalApi/` +// — a non-existent extra folder synthesized from the host assembly name — +// and bombs with `DirectoryNotFoundException`. The Tests project lives +// INSIDE the host project (`samples/CqrsMinimalApi/Tests/`) rather than as +// a sibling, which is the layout the default discovery assumes. +// +// The contentRootPath is relative to the test assembly's bin directory at +// runtime. From `samples/CqrsMinimalApi/Tests/bin/Debug/net10.0/`, four +// levels up gets us to `samples/CqrsMinimalApi/` where the host's +// `appsettings.json` lives — which is what Alba/WAF needs to anchor on. +[assembly: WebApplicationFactoryContentRoot( + "CqrsMinimalApi", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/CqrsMinimalApi/Tests/CqrsMinimalApiHost.cs b/src/Samples/Bobcat/CqrsMinimalApi/Tests/CqrsMinimalApiHost.cs new file mode 100644 index 000000000..6aa0c013d --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/Tests/CqrsMinimalApiHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace CqrsMinimalApi.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class CqrsMinimalApiHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/CqrsMinimalApi/Tests/CqrsMinimalApiSpec.cs b/src/Samples/Bobcat/CqrsMinimalApi/Tests/CqrsMinimalApiSpec.cs new file mode 100644 index 000000000..c9c2fe458 --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/Tests/CqrsMinimalApiSpec.cs @@ -0,0 +1,53 @@ +using Alba; +using Bobcat.CritterStack; + +namespace CqrsMinimalApi.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every CqrsMinimalApi spec: the HTTP calls a step makes, over Alba, and an empty store +/// before every spec. +/// +/// +/// The status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a spec +/// asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +public abstract class CqrsMinimalApiSpec(CqrsMinimalApiHost app) : IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // The host seeds five students at start-up; emptying the store before every spec is what makes + // "at least 2 students are returned" mean the two the spec created. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } +} diff --git a/src/Samples/Bobcat/CqrsMinimalApi/Tests/StudentSpecs.cs b/src/Samples/Bobcat/CqrsMinimalApi/Tests/StudentSpecs.cs new file mode 100644 index 000000000..bad8e958c --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/Tests/StudentSpecs.cs @@ -0,0 +1,131 @@ +namespace CqrsMinimalApi.Tests; + +/// +/// The Student endpoints, driven over HTTP the way a client would. Reuses the host's own Student and +/// request types via the project reference, so the specs and the endpoints cannot drift on shape. +/// +[BobcatFeature("CQRS Minimal API Students")] +public class StudentSpecs(CqrsMinimalApiHost app) : CqrsMinimalApiSpec(app) +{ + private Guid _studentId; + private int _lastStatusCode; + private List _students = []; + + [BobcatSpec] + public async Task create_a_student() + { + await WhenAStudentIsCreated("Alice Smith", "alice@school.com"); + + ThenTheResponseStatusIs(201); + ThenTheStudentIdIsReturned(); + } + + [BobcatSpec] + public async Task get_all_students_ordered() + { + await GivenAStudent("Zara", "zara@school.com"); + await GivenAStudent("Aaron", "aaron@school.com"); + + await WhenAllStudentsAreRead(); + + ThenAtLeastStudentsAreReturned(2); + ThenTheyAreOrderedByName(); + } + + [BobcatSpec] + public async Task get_student_by_id_returns_404_for_missing() + { + await WhenTheStudentIsRead(Guid.Empty); + + ThenTheResponseStatusIs(404); + } + + [BobcatSpec] + public async Task update_a_student() + { + await GivenAStudent("Bob Jones", "bob@school.com"); + + await WhenTheStudentIsRenamed("Robert Jones"); + + ThenTheResponseStatusIs(200); + } + + [BobcatSpec] + public async Task delete_student_and_verify_404() + { + await GivenAStudent("Carol White", "carol@school.com"); + + await WhenTheStudentIsDeleted(); + ThenTheResponseStatusIs(204); + + await WhenTheStudentIsRead(_studentId); + ThenTheResponseStatusIs(404); + } + + // ---- steps ------------------------------------------------------------------------------ + + [Given("a student named \"{name}\" with email \"{email}\"")] + public Task GivenAStudent(string name, string email) => createStudent(name, email); + + [When("a student named \"{name}\" with email \"{email}\" is created")] + public Task WhenAStudentIsCreated(string name, string email) => createStudent(name, email); + + private async Task createStudent(string name, string email) + { + var result = await PostJsonAsync( + "/api/students", new CreateStudentRequest(name, email)); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null && _studentId == Guid.Empty) _studentId = result.Body.Id; + } + + [When("all students are read")] + public async Task WhenAllStudentsAreRead() + { + var result = await GetJsonAsync>("/api/students"); + _lastStatusCode = result.StatusCode; + _students = result.Body ?? []; + } + + [When("the student with id {id} is read")] + public async Task WhenTheStudentIsRead(Guid id) + { + var result = await GetJsonAsync($"/api/students/{id}"); + _lastStatusCode = result.StatusCode; + } + + [When("the student is renamed to \"{newName}\"")] + public async Task WhenTheStudentIsRenamed(string newName) + { + // The POST alias for update, so the spec exercises a full round trip without committing to a + // verb; the host accepts both POST and PUT at /api/students/{id}. + var result = await PostJsonAsync( + $"/api/students/{_studentId}", new UpdateStudentRequest(newName)); + + _lastStatusCode = result.StatusCode; + } + + [When("the student is deleted")] + public async Task WhenTheStudentIsDeleted() + { + var result = await DeleteAsync($"/api/students/{_studentId}"); + _lastStatusCode = result.StatusCode; + } + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + [Then("the student id is returned")] + public void ThenTheStudentIdIsReturned() => SpecAssert.Fact(_studentId != Guid.Empty, "No student id came back."); + + [Then("at least {count} students are returned")] + public void ThenAtLeastStudentsAreReturned(int count) + => SpecAssert.Fact(_students.Count >= count, $"Expected at least {count} students, got {_students.Count}."); + + [Then("they are ordered by name")] + public void ThenTheyAreOrderedByName() + { + var names = _students.Select(s => s.Name).ToList(); + SpecAssert.Fact(names.SequenceEqual(names.OrderBy(n => n)), $"Not ordered by name: {string.Join(", ", names)}"); + } +} diff --git a/src/Samples/Bobcat/CqrsMinimalApi/Tests/Tests.csproj b/src/Samples/Bobcat/CqrsMinimalApi/Tests/Tests.csproj new file mode 100644 index 000000000..2359aa897 --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + CqrsMinimalApi.Tests + CqrsMinimalApi.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/CqrsMinimalApi/Tests/Usings.cs b/src/Samples/Bobcat/CqrsMinimalApi/Tests/Usings.cs new file mode 100644 index 000000000..c17dbd16f --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/Tests/Usings.cs @@ -0,0 +1,3 @@ +global using Bobcat; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/CqrsMinimalApi/appsettings.json b/src/Samples/Bobcat/CqrsMinimalApi/appsettings.json new file mode 100644 index 000000000..ffbda5bf8 --- /dev/null +++ b/src/Samples/Bobcat/CqrsMinimalApi/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=cqrs_minimal_api;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/Directory.Build.props b/src/Samples/Bobcat/Directory.Build.props new file mode 100644 index 000000000..2f45f7955 --- /dev/null +++ b/src/Samples/Bobcat/Directory.Build.props @@ -0,0 +1,42 @@ + + + + + $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', '..')) + + + + true + true + true + + + false + + + + + + + + + + + diff --git a/src/Samples/Bobcat/Directory.Packages.props b/src/Samples/Bobcat/Directory.Packages.props new file mode 100644 index 000000000..a79e77a0f --- /dev/null +++ b/src/Samples/Bobcat/Directory.Packages.props @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Basket/Basket.csproj b/src/Samples/Bobcat/EcommerceMicroservices/Basket/Basket.csproj new file mode 100644 index 000000000..d89bed594 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Basket/Basket.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Basket/CheckoutBasket.cs b/src/Samples/Bobcat/EcommerceMicroservices/Basket/CheckoutBasket.cs new file mode 100644 index 000000000..4905883ee --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Basket/CheckoutBasket.cs @@ -0,0 +1,78 @@ +using Marten; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Basket; + +public record CheckoutBasket( + string UserName, + Guid CustomerId, + // Shipping + string FirstName, + string LastName, + string EmailAddress, + string AddressLine, + string Country, + string State, + string ZipCode, + // Payment + string CardName, + string CardNumber, + string Expiration, + string CVV, + int PaymentMethod +); + +public static class CheckoutBasketEndpoint +{ + public static async Task ValidateAsync( + CheckoutBasket command, + IQuerySession session) + { + var cart = await session.LoadAsync(command.UserName); + if (cart is null || cart.Items.Count == 0) + return new ProblemDetails + { + Detail = $"Basket not found for user '{command.UserName}'", + Status = 404, + }; + + return WolverineContinue.NoProblems; + } + + // Cascade: first element is HTTP response (bool), second is the integration event + // published via Wolverine outbox to be consumed by the Ordering service. + // After publishing, delete the basket. + [WolverinePost("/basket/checkout")] + public static async Task<(bool, BasketCheckoutEvent)> Post( + CheckoutBasket command, + IDocumentSession session, + CancellationToken ct) + { + var cart = await session.LoadAsync(command.UserName, ct); + + var checkoutEvent = new BasketCheckoutEvent( + command.UserName, + command.CustomerId, + cart!.TotalPrice, + command.FirstName, + command.LastName, + command.EmailAddress, + command.AddressLine, + command.Country, + command.State, + command.ZipCode, + command.CardName, + command.CardNumber, + command.Expiration, + command.CVV, + command.PaymentMethod + ); + + session.Delete(cart); + + return (true, checkoutEvent); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Basket/DeleteBasket.cs b/src/Samples/Bobcat/EcommerceMicroservices/Basket/DeleteBasket.cs new file mode 100644 index 000000000..b57e54d1b --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Basket/DeleteBasket.cs @@ -0,0 +1,14 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Basket; + +public static class DeleteBasketEndpoint +{ + [WolverineDelete("/basket/{userName}")] + public static void Delete(string userName, [Entity(Required = true)] ShoppingCart cart, IDocumentSession session) + { + session.Delete(cart); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Basket/GetBasket.cs b/src/Samples/Bobcat/EcommerceMicroservices/Basket/GetBasket.cs new file mode 100644 index 000000000..0aa75af8f --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Basket/GetBasket.cs @@ -0,0 +1,10 @@ +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Basket; + +public static class GetBasketEndpoint +{ + [WolverineGet("/basket/{userName}")] + public static ShoppingCart? Get(string userName, [Entity] ShoppingCart? cart) => cart; +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Basket/Program.cs b/src/Samples/Bobcat/EcommerceMicroservices/Basket/Program.cs new file mode 100644 index 000000000..e837be4d4 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Basket/Program.cs @@ -0,0 +1,58 @@ +using Marten; +using Shared; +using Wolverine; +using Wolverine.Http; +using Wolverine.Marten; +using Wolverine.RabbitMQ; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=basket;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "basket"; + + // ShoppingCart uses UserName (string) as identity + opts.Schema.For().Identity(x => x.Id); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.ServiceName = "Basket"; + + // Publish BasketCheckoutEvent to RabbitMQ for the Ordering service + opts.UseRabbitMq(rabbit => + { + rabbit.HostName = builder.Configuration["RabbitMQ:Host"] ?? "localhost"; + }).AutoProvision(); + + opts.Publish(x => + { + x.Message(); + x.ToRabbitQueue("basket-checkout"); + }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(); + +await app.RunAsync(); diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Basket/ShoppingCart.cs b/src/Samples/Bobcat/EcommerceMicroservices/Basket/ShoppingCart.cs new file mode 100644 index 000000000..0a8281d6d --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Basket/ShoppingCart.cs @@ -0,0 +1,19 @@ +namespace Basket; + +public class ShoppingCart +{ + // UserName as the identity — Marten supports string IDs + public string Id { get; set; } = string.Empty; + public string UserName => Id; + public List Items { get; set; } = []; + public decimal TotalPrice => Items.Sum(i => i.Price * i.Quantity); +} + +public class ShoppingCartItem +{ + public int Quantity { get; set; } + public string Color { get; set; } = string.Empty; + public decimal Price { get; set; } + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Basket/StoreBasket.cs b/src/Samples/Bobcat/EcommerceMicroservices/Basket/StoreBasket.cs new file mode 100644 index 000000000..7e6bd1650 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Basket/StoreBasket.cs @@ -0,0 +1,16 @@ +using Marten; +using Wolverine.Http; + +namespace Basket; + +public record StoreBasket(ShoppingCart Cart); + +public static class StoreBasketEndpoint +{ + [WolverinePost("/basket")] + public static ShoppingCart Post(StoreBasket command, IDocumentSession session) + { + session.Store(command.Cart); + return command.Cart; + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Basket/appsettings.json b/src/Samples/Bobcat/EcommerceMicroservices/Basket/appsettings.json new file mode 100644 index 000000000..ffbda5bf8 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Basket/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=cqrs_minimal_api;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Catalog.csproj b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Catalog.csproj new file mode 100644 index 000000000..dbc9b498c --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Catalog.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Catalog/CreateProduct.cs b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/CreateProduct.cs new file mode 100644 index 000000000..e834c6386 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/CreateProduct.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using Marten; +using Wolverine.Http; + +namespace Catalog; + +public record CreateProduct(string Name, List Category, string Description, string ImageFile, decimal Price) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.Category).NotEmpty(); + RuleFor(x => x.Price).GreaterThan(0); + } + } +} + +public static class CreateProductEndpoint +{ + [WolverinePost("/products")] + public static Product Post(CreateProduct command, IDocumentSession session) + { + var product = new Product + { + Id = Guid.NewGuid(), + Name = command.Name, + Category = command.Category, + Description = command.Description, + ImageFile = command.ImageFile, + Price = command.Price, + }; + + session.Store(product); + return product; + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Catalog/DeleteProduct.cs b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/DeleteProduct.cs new file mode 100644 index 000000000..d008c375e --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/DeleteProduct.cs @@ -0,0 +1,14 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Catalog; + +public static class DeleteProductEndpoint +{ + [WolverineDelete("/products/{id}")] + public static void Delete(Guid id, [Entity(Required = true)] Product product, IDocumentSession session) + { + session.Delete(product); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Catalog/GetProducts.cs b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/GetProducts.cs new file mode 100644 index 000000000..f7f2bcb63 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/GetProducts.cs @@ -0,0 +1,27 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Catalog; + +public static class GetProductsEndpoint +{ + [WolverineGet("/products")] + public static Task> Get(IQuerySession session, CancellationToken ct) + => session.Query().ToListAsync(ct); +} + +public static class GetProductByIdEndpoint +{ + [WolverineGet("/products/{id}")] + public static Product? Get(Guid id, [Entity] Product? product) => product; +} + +public static class GetProductByCategoryEndpoint +{ + [WolverineGet("/products/category/{category}")] + public static Task> Get(string category, IQuerySession session, CancellationToken ct) + => session.Query() + .Where(p => p.Category.Contains(category)) + .ToListAsync(ct); +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Product.cs b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Product.cs new file mode 100644 index 000000000..f48a926ed --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Product.cs @@ -0,0 +1,11 @@ +namespace Catalog; + +public class Product +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public List Category { get; set; } = []; + public string Description { get; set; } = string.Empty; + public string ImageFile { get; set; } = string.Empty; + public decimal Price { get; set; } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Program.cs b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Program.cs new file mode 100644 index 000000000..0636f0fed --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/Program.cs @@ -0,0 +1,66 @@ +using Wolverine.FluentValidation; +using Marten; +using Wolverine; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=catalog;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "catalog"; +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); + opts.ServiceName = "Catalog"; +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +await SeedData(app); + +await app.RunAsync(); + +static async Task SeedData(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + if (await session.Query().AnyAsync()) return; + + session.Store( + new Catalog.Product { Name = "IPhone X", Category = ["Smart Phone"], Description = "Apple smartphone", ImageFile = "product-1.png", Price = 950.00m }, + new Catalog.Product { Name = "Samsung 10", Category = ["Smart Phone"], Description = "Samsung smartphone", ImageFile = "product-2.png", Price = 840.00m }, + new Catalog.Product { Name = "Huawei Plus", Category = ["White Appliances"], Description = "Huawei phone", ImageFile = "product-3.png", Price = 650.00m }, + new Catalog.Product { Name = "Xiaomi Mi 9", Category = ["Smart Phone"], Description = "Xiaomi smartphone", ImageFile = "product-4.png", Price = 470.00m }, + new Catalog.Product { Name = "HTC U11+", Category = ["Smart Phone"], Description = "HTC phone", ImageFile = "product-5.png", Price = 380.00m } + ); + await session.SaveChangesAsync(); +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Catalog/UpdateProduct.cs b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/UpdateProduct.cs new file mode 100644 index 000000000..ed2201501 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/UpdateProduct.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Catalog; + +public record UpdateProduct(Guid Id, string Name, List Category, string Description, string ImageFile, decimal Price) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.Price).GreaterThan(0); + } + } +} + +public static class UpdateProductEndpoint +{ + [WolverinePut("/products")] + public static Product Put(UpdateProduct command, [Entity(Required = true)] Product product, IDocumentSession session) + { + product.Name = command.Name; + product.Category = command.Category; + product.Description = command.Description; + product.ImageFile = command.ImageFile; + product.Price = command.Price; + + session.Store(product); + return product; + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Catalog/appsettings.json b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/appsettings.json new file mode 100644 index 000000000..ffbda5bf8 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Catalog/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=cqrs_minimal_api;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Discount/Coupon.cs b/src/Samples/Bobcat/EcommerceMicroservices/Discount/Coupon.cs new file mode 100644 index 000000000..b75cf11d9 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Discount/Coupon.cs @@ -0,0 +1,9 @@ +namespace Discount; + +public class Coupon +{ + public Guid Id { get; set; } + public string ProductName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Amount { get; set; } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Discount/CouponEndpoints.cs b/src/Samples/Bobcat/EcommerceMicroservices/Discount/CouponEndpoints.cs new file mode 100644 index 000000000..ded9f30dc --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Discount/CouponEndpoints.cs @@ -0,0 +1,54 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Discount; + +public record CreateCoupon(string ProductName, string Description, decimal Amount); +public record UpdateCoupon(Guid Id, string ProductName, string Description, decimal Amount); + +public static class GetCouponEndpoint +{ + [WolverineGet("/discounts/{productName}")] + public static async Task Get(string productName, IQuerySession session, CancellationToken ct) + => await session.Query().FirstOrDefaultAsync(c => c.ProductName == productName, ct); +} + +public static class CreateCouponEndpoint +{ + [WolverinePost("/discounts")] + public static Coupon Post(CreateCoupon command, IDocumentSession session) + { + var coupon = new Coupon + { + Id = Guid.NewGuid(), + ProductName = command.ProductName, + Description = command.Description, + Amount = command.Amount, + }; + session.Store(coupon); + return coupon; + } +} + +public static class UpdateCouponEndpoint +{ + [WolverinePut("/discounts")] + public static Coupon Put(UpdateCoupon command, [Entity(Required = true)] Coupon coupon, IDocumentSession session) + { + coupon.ProductName = command.ProductName; + coupon.Description = command.Description; + coupon.Amount = command.Amount; + session.Store(coupon); + return coupon; + } +} + +public static class DeleteCouponEndpoint +{ + [WolverineDelete("/discounts/{id}")] + public static void Delete(Guid id, [Entity(Required = true)] Coupon coupon, IDocumentSession session) + { + session.Delete(coupon); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Discount/Discount.csproj b/src/Samples/Bobcat/EcommerceMicroservices/Discount/Discount.csproj new file mode 100644 index 000000000..1bec996d6 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Discount/Discount.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Discount/Program.cs b/src/Samples/Bobcat/EcommerceMicroservices/Discount/Program.cs new file mode 100644 index 000000000..868aaa1f6 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Discount/Program.cs @@ -0,0 +1,58 @@ +using Marten; +using Wolverine; +using Wolverine.Http; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=discount;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "discount"; + opts.Schema.For().Index(x => x.ProductName); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.ServiceName = "Discount"; +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(); + +await SeedData(app); + +await app.RunAsync(); + +static async Task SeedData(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + if (await session.Query().AnyAsync()) return; + + session.Store( + new Discount.Coupon { ProductName = "IPhone X", Description = "IPhone Discount", Amount = 150 }, + new Discount.Coupon { ProductName = "Samsung 10", Description = "Samsung Discount", Amount = 100 } + ); + await session.SaveChangesAsync(); +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Discount/appsettings.json b/src/Samples/Bobcat/EcommerceMicroservices/Discount/appsettings.json new file mode 100644 index 000000000..ffbda5bf8 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Discount/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=cqrs_minimal_api;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/EcommerceMicroservices.slnx b/src/Samples/Bobcat/EcommerceMicroservices/EcommerceMicroservices.slnx new file mode 100644 index 000000000..4e4f51498 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/EcommerceMicroservices.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Ordering/BasketCheckoutEventHandler.cs b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/BasketCheckoutEventHandler.cs new file mode 100644 index 000000000..7bd8a29e5 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/BasketCheckoutEventHandler.cs @@ -0,0 +1,41 @@ +using Marten; +using Shared; + +namespace Ordering; + +/// +/// Handles BasketCheckoutEvent from the Basket service. +/// Replaces MassTransit IConsumer + MediatR CreateOrderCommand roundtrip. +/// The original had: MassTransit consumer → MediatR Send → CreateOrderHandler → EF Core. +/// Now it's a single Wolverine handler that creates the Order directly. +/// +public static class BasketCheckoutEventHandler +{ + public static void Handle(BasketCheckoutEvent message, IDocumentSession session) + { + var order = new Order + { + Id = Guid.NewGuid(), + CustomerId = message.CustomerId, + OrderName = $"Order-{message.UserName}-{DateTime.UtcNow.Ticks}", + FirstName = message.FirstName, + LastName = message.LastName, + EmailAddress = message.EmailAddress, + AddressLine = message.AddressLine, + Country = message.Country, + State = message.State, + ZipCode = message.ZipCode, + CardName = message.CardName, + CardNumber = message.CardNumber, + Expiration = message.Expiration, + CVV = message.CVV, + PaymentMethod = message.PaymentMethod, + Status = OrderStatus.Pending, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(order); + + Console.WriteLine($"[OrderCreated] Order {order.Id} created for customer {message.CustomerId} (total: {message.TotalPrice:C})"); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Ordering/CreateOrder.cs b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/CreateOrder.cs new file mode 100644 index 000000000..45abb7f76 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/CreateOrder.cs @@ -0,0 +1,66 @@ +using FluentValidation; +using Marten; +using Wolverine.Http; + +namespace Ordering; + +public record CreateOrder( + Guid CustomerId, + string OrderName, + string FirstName, + string LastName, + string EmailAddress, + string AddressLine, + string Country, + string State, + string ZipCode, + string CardName, + string CardNumber, + string Expiration, + string CVV, + int PaymentMethod, + List Items +) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.OrderName).NotEmpty(); + RuleFor(x => x.CustomerId).NotEmpty(); + RuleFor(x => x.Items).NotEmpty(); + } + } +} + +public static class CreateOrderEndpoint +{ + [WolverinePost("/orders")] + public static Order Post(CreateOrder command, IDocumentSession session) + { + var order = new Order + { + Id = Guid.NewGuid(), + CustomerId = command.CustomerId, + OrderName = command.OrderName, + FirstName = command.FirstName, + LastName = command.LastName, + EmailAddress = command.EmailAddress, + AddressLine = command.AddressLine, + Country = command.Country, + State = command.State, + ZipCode = command.ZipCode, + CardName = command.CardName, + CardNumber = command.CardNumber, + Expiration = command.Expiration, + CVV = command.CVV, + PaymentMethod = command.PaymentMethod, + Status = OrderStatus.Pending, + Items = command.Items, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(order); + return order; + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Ordering/DeleteOrder.cs b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/DeleteOrder.cs new file mode 100644 index 000000000..22cf8bd6d --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/DeleteOrder.cs @@ -0,0 +1,14 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Ordering; + +public static class DeleteOrderEndpoint +{ + [WolverineDelete("/orders/{id}")] + public static void Delete(Guid id, [Entity(Required = true)] Order order, IDocumentSession session) + { + session.Delete(order); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Ordering/GetOrders.cs b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/GetOrders.cs new file mode 100644 index 000000000..6ce2ddeeb --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/GetOrders.cs @@ -0,0 +1,40 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Ordering; + +public static class GetOrdersEndpoint +{ + [WolverineGet("/orders")] + public static async Task> Get(IQuerySession session, CancellationToken ct) + { + var orders = await session.Query() + .OrderByDescending(o => o.CreatedAt) + .ToListAsync(ct); + + return orders.Select(o => new OrderDto(o.Id, o.CustomerId, o.OrderName, o.TotalPrice, o.Status, o.Items)).ToList(); + } +} + +public static class GetOrderByIdEndpoint +{ + [WolverineGet("/orders/{id}")] + public static OrderDto? Get(Guid id, [Entity] Order? order) + => order is not null + ? new OrderDto(order.Id, order.CustomerId, order.OrderName, order.TotalPrice, order.Status, order.Items) + : null; +} + +public static class GetOrdersByCustomerEndpoint +{ + [WolverineGet("/orders/customer/{customerId}")] + public static async Task> Get(Guid customerId, IQuerySession session, CancellationToken ct) + { + var orders = await session.Query() + .Where(o => o.CustomerId == customerId) + .ToListAsync(ct); + + return orders.Select(o => new OrderDto(o.Id, o.CustomerId, o.OrderName, o.TotalPrice, o.Status, o.Items)).ToList(); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Order.cs b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Order.cs new file mode 100644 index 000000000..9b74b685b --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Order.cs @@ -0,0 +1,66 @@ +namespace Ordering; + +/// +/// Order document stored in Marten. Replaces the DDD aggregate with +/// ValueObjects (OrderId, CustomerId, Address, Payment) and the separate +/// Customer, Product, OrderItem entities from 4 EF Core configuration classes. +/// Marten stores this as a single JSON document — no joins needed. +/// +public class Order +{ + public Guid Id { get; set; } + public Guid CustomerId { get; set; } + public string OrderName { get; set; } = string.Empty; + + // Shipping address (was Address ValueObject) + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; + public string AddressLine { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + + // Payment (was Payment ValueObject) + public string CardName { get; set; } = string.Empty; + public string CardNumber { get; set; } = string.Empty; + public string Expiration { get; set; } = string.Empty; + public string CVV { get; set; } = string.Empty; + public int PaymentMethod { get; set; } + + public OrderStatus Status { get; set; } = OrderStatus.Pending; + public List Items { get; set; } = []; + public decimal TotalPrice => Items.Sum(i => i.Price * i.Quantity); + + // Audit + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } +} + +public class OrderItem +{ + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public int Quantity { get; set; } + public decimal Price { get; set; } +} + +public enum OrderStatus +{ + Draft, + Pending, + Completed, + Cancelled, +} + +/// +/// DTO for API responses — keeps internal document shape private. +/// +public record OrderDto( + Guid Id, + Guid CustomerId, + string OrderName, + decimal TotalPrice, + OrderStatus Status, + IReadOnlyList Items +); diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Ordering.csproj b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Ordering.csproj new file mode 100644 index 000000000..d440d4fbc --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Ordering.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Program.cs b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Program.cs new file mode 100644 index 000000000..9dc3cb574 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/Program.cs @@ -0,0 +1,59 @@ +using Wolverine.FluentValidation; +using Marten; +using Wolverine; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; +using Wolverine.RabbitMQ; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=ordering;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "ordering"; + + opts.Schema.For().Index(x => x.CustomerId); + opts.Schema.For().Index(x => x.OrderName); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); + opts.ServiceName = "Ordering"; + + // Listen for BasketCheckoutEvent from the Basket service via RabbitMQ + opts.UseRabbitMq(rabbit => + { + rabbit.HostName = builder.Configuration["RabbitMQ:Host"] ?? "localhost"; + }).AutoProvision(); + + opts.ListenToRabbitQueue("basket-checkout"); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +await app.RunAsync(); diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Ordering/appsettings.json b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/appsettings.json new file mode 100644 index 000000000..ffbda5bf8 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Ordering/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=cqrs_minimal_api;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/README.md b/src/Samples/Bobcat/EcommerceMicroservices/README.md new file mode 100644 index 000000000..140cb6091 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/README.md @@ -0,0 +1,97 @@ +# E-Commerce Microservices (RabbitMQ) — Critter Stack Conversion + +> **See also:** `EcommerceModularMonolith/` — the same domain collapsed into a single application with durable local queues. + +## Original Project + +**Repository:** [aspnetrun/run-aspnetcore-microservices](https://github.com/aspnetrun/run-aspnetcore-microservices) +**License:** MIT +**Stack:** ASP.NET Core 8, MediatR, MassTransit (RabbitMQ), Carter, EF Core (SQL Server), Marten (Catalog & Basket), gRPC (Discount), Redis, MongoDB, YARP API Gateway + +A full e-commerce microservices application with 4 services, API gateway, and Razor Pages web app. The most compelling migration target in the inventory because it already uses Marten in two services. + +## What Changed + +### Architecture + +| Aspect | Original | Converted | +|--------|----------|-----------| +| Projects | 12 (4 services × layers + BuildingBlocks + Gateway + WebApp) | 5 (4 services + Shared) | +| Ordering service | 4 projects (API, Application, Domain, Infrastructure) | 1 project | +| Databases | PostgreSQL + SQL Server + SQLite + Redis + MongoDB | PostgreSQL only (Marten) | +| Message bus | MassTransit + RabbitMQ | Wolverine cascading messages (+ RabbitMQ when needed) | +| CQRS dispatch | MediatR + pipeline behaviors | Wolverine handlers (direct) | +| Discount transport | gRPC + Protocol Buffers | Wolverine.HTTP (plain REST) | +| API Gateway | YARP reverse proxy | Removed (each service is independently deployable) | + +### Per-Service Changes + +**Catalog** (was ~15 files across handlers + endpoints): +- Already used Marten — kept as-is +- Replaced 6 MediatR handlers + 6 Carter endpoints with 6 Wolverine endpoints +- Added `[Entity]` for get-by-id, update, delete operations +- Now 5 files + +**Basket** (was ~12 files + Redis caching layer): +- Already used Marten — kept as-is +- Replaced MediatR handlers + MassTransit publisher with Wolverine endpoints +- `CheckoutBasket` uses cascading message tuple `(bool, BasketCheckoutEvent)` instead of `IPublishEndpoint` +- Removed `IBasketRepository` / `CachedBasketRepository` decorator — direct Marten session +- Now 5 files + +**Ordering** (was 4 projects, ~40 files with DDD + Clean Architecture): +- Collapsed Domain + Application + Infrastructure + API into 1 project +- Replaced EF Core (SQL Server) with Marten (PostgreSQL) +- Replaced DDD ValueObjects (OrderId, CustomerId, Address, Payment) with flat document properties +- Replaced MassTransit `IConsumer` with Wolverine handler +- Eliminated: AuditableEntityInterceptor, DispatchDomainEventsInterceptor, 4 EF configurations, DbUp migrations +- Now 6 files + +**Discount** (was gRPC service with Protocol Buffers): +- Replaced gRPC + Mapster + EF Core (SQLite) with Wolverine.HTTP + Marten +- Now 4 files + +### Removed Entirely +- **BuildingBlocks** — MediatR CQRS interfaces, validation behavior, logging behavior, MassTransit extensions +- **YARP API Gateway** — rate limiting, reverse proxy configuration +- **Shopping.Web** — Razor Pages frontend with Refit HTTP clients +- **Redis** — caching layer for basket +- **MongoDB** — not used in the original (was in earlier versions) + +### What Stays the Same +- Domain concepts: Products, Shopping Carts, Orders, Coupons +- Service boundaries: Catalog, Basket, Ordering, Discount +- Integration event flow: Basket checkout → Order creation +- REST API contracts (same routes, same payloads) + +## Running + +Requires PostgreSQL. Each service needs its own database. Configure connection strings in each service's `appsettings.json`, then run each service independently: + +```bash +cd Catalog && dotnet run +cd Basket && dotnet run +cd Ordering && dotnet run +cd Discount && dotnet run +``` + +Each service has Swagger UI at `/swagger`. + +## Specifications + +`Tests/` holds Bobcat's projected (code-first) specs for the Catalog service: ordinary xUnit v3 tests +that render as Given/When/Then specifications, driving the service through Alba. `ProductSpecs` covers +create, list, read by id and by category, update and delete, and the 400s and 404s around them. Every +step is a method on that class with a `[Given]`, `[When]` or `[Then]` attribute, so calling it records +it. + +```bash +dotnet test --project Tests # the specs (Postgres, catalog database) +BOBCAT_SPEC_CONSOLE=1 dotnet run --project Tests # ...and print every spec as it rendered +``` + +The specs found two things the build never could. None of the four services could start: Wolverine 6 +stopped shipping the runtime compiler in core, and each now references `WolverineFx.RuntimeCompilation`. +And the old `.feature` file had never run against this API -- it expected a 201 from `POST /products`, +which answers 200 with the stored product, and an update at `POST /products/{id}`, where the service +takes `PUT /products` with the id in the body. diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Shared/IntegrationEvents.cs b/src/Samples/Bobcat/EcommerceMicroservices/Shared/IntegrationEvents.cs new file mode 100644 index 000000000..f33d13c46 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Shared/IntegrationEvents.cs @@ -0,0 +1,26 @@ +namespace Shared; + +/// +/// Integration event published by Basket service when checkout completes. +/// Consumed by the Ordering service to create a new order. +/// Replaces MassTransit's BasketCheckoutEvent + BuildingBlocks.Messaging. +/// +public record BasketCheckoutEvent( + string UserName, + Guid CustomerId, + decimal TotalPrice, + // Shipping + string FirstName, + string LastName, + string EmailAddress, + string AddressLine, + string Country, + string State, + string ZipCode, + // Payment + string CardName, + string CardNumber, + string Expiration, + string CVV, + int PaymentMethod +); diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Shared/Shared.csproj b/src/Samples/Bobcat/EcommerceMicroservices/Shared/Shared.csproj new file mode 100644 index 000000000..9623deb43 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Shared/Shared.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/EcommerceMicroservices/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..6d2a1b868 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Tests/AssemblyAttributes.cs @@ -0,0 +1,18 @@ +using EcommerceMicroservices.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(EcommerceMicroservicesHost))] + +// Every spec empties the store before it runs (see EcommerceMicroservicesSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tell ASP.NET Core's WebApplicationFactory (used by Alba) where to find the host project's content +// root. The path is relative to the test assembly's bin directory at run time: from +// `Tests/bin//net10.0/`, four levels up is the sample's folder. +[assembly: WebApplicationFactoryContentRoot( + "Catalog", + "../../../../Catalog", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Tests/EcommerceMicroservicesHost.cs b/src/Samples/Bobcat/EcommerceMicroservices/Tests/EcommerceMicroservicesHost.cs new file mode 100644 index 000000000..37bfaf8fd --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Tests/EcommerceMicroservicesHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace EcommerceMicroservices.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class EcommerceMicroservicesHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Tests/EcommerceMicroservicesSpec.cs b/src/Samples/Bobcat/EcommerceMicroservices/Tests/EcommerceMicroservicesSpec.cs new file mode 100644 index 000000000..4897cdb6f --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Tests/EcommerceMicroservicesSpec.cs @@ -0,0 +1,52 @@ +using Alba; +using Bobcat.CritterStack; + +namespace EcommerceMicroservices.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every EcommerceMicroservices spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// The status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a spec +/// asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +public abstract class EcommerceMicroservicesSpec(EcommerceMicroservicesHost app) : IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // The Catalog host seeds five products at start-up; emptying the store before every spec is what makes "at least 1 product is returned" mean the one the spec created. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Tests/ProductSpecs.cs b/src/Samples/Bobcat/EcommerceMicroservices/Tests/ProductSpecs.cs new file mode 100644 index 000000000..4c65e9673 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Tests/ProductSpecs.cs @@ -0,0 +1,199 @@ +using Catalog; + +namespace EcommerceMicroservices.Tests; + +/// +/// The Catalog service's product endpoints, driven over HTTP. The specs reuse the service's own command +/// and document types via the project reference, so they cannot drift from the API's shape. +/// +[BobcatFeature("Ecommerce Microservices Products")] +public class ProductSpecs(EcommerceMicroservicesHost app) : EcommerceMicroservicesSpec(app) +{ + private Guid _productId; + private int _lastStatusCode; + private Product? _product; + private List _products = []; + + [BobcatSpec] + public async Task create_a_product() + { + await WhenAProductIsCreated("Widget", "Tools", 9.99m); + + ThenTheResponseStatusIs(200); + ThenTheProductIdIsReturned(); + } + + [BobcatSpec] + public async Task create_product_with_invalid_data_returns_400() + { + await WhenAProductIsCreated("", "Category", 1.0m); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task get_product_list() + { + await GivenAProduct("Gadget", "Electronics", 29.99m); + + await WhenAllProductsAreRead(); + + ThenAtLeastProductsAreReturned(1); + } + + [BobcatSpec] + public async Task get_product_by_id() + { + await GivenAProduct("Gizmo", "Electronics", 14.99m); + + await WhenTheProductIsRead(_productId); + + ThenTheResponseStatusIs(200); + ThenTheProductNameIs("Gizmo"); + } + + [BobcatSpec] + public async Task get_product_by_id_returns_404_for_missing() + { + await WhenTheProductIsRead(Guid.Empty); + + ThenTheResponseStatusIs(404); + } + + [BobcatSpec] + public async Task get_products_by_category() + { + await GivenAProduct("Sprocket", "Parts", 4.99m); + + await WhenTheProductsInCategoryAreRead("Parts"); + + ThenAtLeastProductsAreReturned(1); + } + + [BobcatSpec] + public async Task update_a_product() + { + await GivenAProduct("OldName", "Misc", 1.00m); + + await WhenTheProductIsUpdated("NewName", 2.00m); + + ThenTheResponseStatusIs(200); + } + + [BobcatSpec] + public async Task update_product_with_invalid_data_returns_400() + { + await GivenAProduct("ValidProduct", "Misc", 1.00m); + + await WhenTheProductIsUpdated("", 1.00m); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task delete_a_product() + { + await GivenAProduct("ToDelete", "Misc", 1.00m); + + await WhenTheProductIsDeleted(_productId); + + ThenTheResponseStatusIs(204); + } + + [BobcatSpec] + public async Task delete_returns_404_for_missing_product() + { + await WhenTheProductIsDeleted(Guid.Empty); + + ThenTheResponseStatusIs(404); + } + + [BobcatSpec] + public async Task delete_nonexistent_product_returns_404() + { + await GivenAProduct("Ephemeral", "Misc", 1.00m); + await GivenTheProductWasDeleted(); + + await WhenTheProductIsDeleted(_productId); + + ThenTheResponseStatusIs(404); + } + + // ---- steps ------------------------------------------------------------------------------ + + [Given("a product named \"{name}\" in category \"{category}\" priced {price}")] + public Task GivenAProduct(string name, string category, decimal price) => createProduct(name, category, price); + + [When("a product named \"{name}\" in category \"{category}\" priced {price} is created")] + public Task WhenAProductIsCreated(string name, string category, decimal price) => createProduct(name, category, price); + + private async Task createProduct(string name, string category, decimal price) + { + var result = await PostJsonAsync( + "/products", new CreateProduct(name, [category], $"{name} description", $"{name}.png", price)); + + _lastStatusCode = result.StatusCode; + if (result.StatusCode is >= 200 and < 300 && result.Body is not null) _productId = result.Body.Id; + } + + [When("all products are read")] + public async Task WhenAllProductsAreRead() + { + var result = await GetJsonAsync>("/products"); + _lastStatusCode = result.StatusCode; + _products = result.Body ?? []; + } + + [When("the product with id {id} is read")] + public async Task WhenTheProductIsRead(Guid id) + { + var result = await GetJsonAsync($"/products/{id}"); + _lastStatusCode = result.StatusCode; + _product = result.Body; + } + + [When("the products in category \"{category}\" are read")] + public async Task WhenTheProductsInCategoryAreRead(string category) + { + var result = await GetJsonAsync>($"/products/category/{category}"); + _lastStatusCode = result.StatusCode; + _products = result.Body ?? []; + } + + [When("the product is updated to name \"{name}\" priced {price}")] + public async Task WhenTheProductIsUpdated(string name, decimal price) + { + // PUT /products with the id in the body; [Entity] loads the Product from the command's Id. + var result = await PutJsonAsync( + "/products", new UpdateProduct(_productId, name, ["Misc"], $"{name} description", $"{name}.png", price)); + + _lastStatusCode = result.StatusCode; + } + + [Given("the product was deleted")] + public Task GivenTheProductWasDeleted() => deleteProduct(_productId); + + [When("the product with id {id} is deleted")] + public Task WhenTheProductIsDeleted(Guid id) => deleteProduct(id); + + private async Task deleteProduct(Guid id) + { + var result = await DeleteAsync($"/products/{id}"); + _lastStatusCode = result.StatusCode; + } + + // #region response-status + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + // #endregion response-status + + [Then("the product id is returned")] + public void ThenTheProductIdIsReturned() => SpecAssert.Fact(_productId != Guid.Empty, "No product id came back."); + + [Then("at least {count} product is returned")] + public void ThenAtLeastProductsAreReturned(int count) + => SpecAssert.Fact(_products.Count >= count, $"Expected at least {count} product(s), got {_products.Count}."); + + [Then("the product name is \"{name}\"")] + public void ThenTheProductNameIs(string name) => SpecAssert.Check("name", _product?.Name, name); +} diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Tests/Tests.csproj b/src/Samples/Bobcat/EcommerceMicroservices/Tests/Tests.csproj new file mode 100644 index 000000000..f6906a31d --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + EcommerceMicroservices.Tests + EcommerceMicroservices.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceMicroservices/Tests/Usings.cs b/src/Samples/Bobcat/EcommerceMicroservices/Tests/Usings.cs new file mode 100644 index 000000000..c17dbd16f --- /dev/null +++ b/src/Samples/Bobcat/EcommerceMicroservices/Tests/Usings.cs @@ -0,0 +1,3 @@ +global using Bobcat; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Basket/CheckoutBasket.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/CheckoutBasket.cs new file mode 100644 index 000000000..207906d68 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/CheckoutBasket.cs @@ -0,0 +1,84 @@ +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Basket; + +public record CheckoutBasket( + string UserName, + Guid CustomerId, + // Shipping + string FirstName, + string LastName, + string EmailAddress, + string AddressLine, + string Country, + string State, + string ZipCode, + // Payment + string CardName, + string CardNumber, + string Expiration, + string CVV, + int PaymentMethod +); + +public static class CheckoutBasketEndpoint +{ + public static async Task ValidateAsync( + CheckoutBasket command, + IQuerySession session) + { + var cart = await session.LoadAsync(command.UserName); + if (cart is null || cart.Items.Count == 0) + return new ProblemDetails + { + Detail = $"Basket not found for user '{command.UserName}'", + Status = 404, + }; + + return WolverineContinue.NoProblems; + } + + // Cascade: the first tuple element is the HTTP response, the second is the integration + // event published through the Wolverine outbox for the Ordering module to consume. After + // publishing, delete the basket. + // + // 202 Accepted, not 201 Created: the order does not exist when this returns. It is created + // by the Ordering module when it handles BasketCheckoutEvent off the durable local queue, + // which is the whole point of the modular split — so the honest answer is "accepted, look + // for the result here", with the customer's orders as the Location. + [WolverinePost("/basket/checkout")] + public static async Task<(Accepted, BasketCheckoutEvent)> Post( + CheckoutBasket command, + IDocumentSession session, + CancellationToken ct) + { + var cart = await session.LoadAsync(command.UserName, ct); + + var checkoutEvent = new BasketCheckoutEvent( + command.UserName, + command.CustomerId, + cart!.TotalPrice, + command.FirstName, + command.LastName, + command.EmailAddress, + command.AddressLine, + command.Country, + command.State, + command.ZipCode, + command.CardName, + command.CardNumber, + command.Expiration, + command.CVV, + command.PaymentMethod + ); + + session.Delete(cart); + + return (TypedResults.Accepted($"/orders/customer/{command.CustomerId}"), checkoutEvent); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Basket/DeleteBasket.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/DeleteBasket.cs new file mode 100644 index 000000000..291330fc5 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/DeleteBasket.cs @@ -0,0 +1,14 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Basket; + +public static class DeleteBasketEndpoint +{ + [WolverineDelete("/basket/{userName}")] + public static void Delete([Entity("userName", Required = true)] ShoppingCart cart, IDocumentSession session) + { + session.Delete(cart); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Basket/GetBasket.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/GetBasket.cs new file mode 100644 index 000000000..1a76e5d4c --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/GetBasket.cs @@ -0,0 +1,10 @@ +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Basket; + +public static class GetBasketEndpoint +{ + [WolverineGet("/basket/{userName}")] + public static ShoppingCart? Get([Entity("userName")] ShoppingCart? cart) => cart; +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Basket/ShoppingCart.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/ShoppingCart.cs new file mode 100644 index 000000000..0a8281d6d --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/ShoppingCart.cs @@ -0,0 +1,19 @@ +namespace Basket; + +public class ShoppingCart +{ + // UserName as the identity — Marten supports string IDs + public string Id { get; set; } = string.Empty; + public string UserName => Id; + public List Items { get; set; } = []; + public decimal TotalPrice => Items.Sum(i => i.Price * i.Quantity); +} + +public class ShoppingCartItem +{ + public int Quantity { get; set; } + public string Color { get; set; } = string.Empty; + public decimal Price { get; set; } + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Basket/StoreBasket.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/StoreBasket.cs new file mode 100644 index 000000000..f96934dc4 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Basket/StoreBasket.cs @@ -0,0 +1,20 @@ +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; + +namespace Basket; + +public record StoreBasket(ShoppingCart Cart); + +public static class StoreBasketEndpoint +{ + // An upsert keyed by user name, answered with 201 and the basket's own URL — the same + // contract the original eShop sample had. See docs/sample-wiring.md footgun 3 for why this + // is a Created and not a (ShoppingCart, IResult) tuple. + [WolverinePost("/basket")] + public static Created Post(StoreBasket command, IDocumentSession session) + { + session.Store(command.Cart); + return TypedResults.Created($"/basket/{command.Cart.Id}", command.Cart); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/CreateProduct.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/CreateProduct.cs new file mode 100644 index 000000000..8abce5267 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/CreateProduct.cs @@ -0,0 +1,44 @@ +using FluentValidation; +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; + +namespace Catalog; + +public record CreateProduct(string Name, List Category, string Description, string ImageFile, decimal Price) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.Category).NotEmpty(); + RuleFor(x => x.Price).GreaterThan(0); + } + } +} + +public static class CreateProductEndpoint +{ + // Created rather than a bare Product, so the response carries the 201 and Location a + // newly-created resource should. Returned as a TypedResults value directly — a + // (Product, IResult) tuple would be read by Wolverine.HTTP as (body, cascaded-message) + // and the IResult dispatched as a message with no handler. See docs/sample-wiring.md + // footgun 3. + [WolverinePost("/products")] + public static Created Post(CreateProduct command, IDocumentSession session) + { + var product = new Product + { + Id = Guid.NewGuid(), + Name = command.Name, + Category = command.Category, + Description = command.Description, + ImageFile = command.ImageFile, + Price = command.Price, + }; + + session.Store(product); + return TypedResults.Created($"/products/{product.Id}", product); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/DeleteProduct.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/DeleteProduct.cs new file mode 100644 index 000000000..d008c375e --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/DeleteProduct.cs @@ -0,0 +1,14 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Catalog; + +public static class DeleteProductEndpoint +{ + [WolverineDelete("/products/{id}")] + public static void Delete(Guid id, [Entity(Required = true)] Product product, IDocumentSession session) + { + session.Delete(product); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/GetProducts.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/GetProducts.cs new file mode 100644 index 000000000..f7f2bcb63 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/GetProducts.cs @@ -0,0 +1,27 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Catalog; + +public static class GetProductsEndpoint +{ + [WolverineGet("/products")] + public static Task> Get(IQuerySession session, CancellationToken ct) + => session.Query().ToListAsync(ct); +} + +public static class GetProductByIdEndpoint +{ + [WolverineGet("/products/{id}")] + public static Product? Get(Guid id, [Entity] Product? product) => product; +} + +public static class GetProductByCategoryEndpoint +{ + [WolverineGet("/products/category/{category}")] + public static Task> Get(string category, IQuerySession session, CancellationToken ct) + => session.Query() + .Where(p => p.Category.Contains(category)) + .ToListAsync(ct); +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/Product.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/Product.cs new file mode 100644 index 000000000..f48a926ed --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/Product.cs @@ -0,0 +1,11 @@ +namespace Catalog; + +public class Product +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public List Category { get; set; } = []; + public string Description { get; set; } = string.Empty; + public string ImageFile { get; set; } = string.Empty; + public decimal Price { get; set; } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/UpdateProduct.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/UpdateProduct.cs new file mode 100644 index 000000000..ed2201501 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Catalog/UpdateProduct.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Catalog; + +public record UpdateProduct(Guid Id, string Name, List Category, string Description, string ImageFile, decimal Price) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.Price).GreaterThan(0); + } + } +} + +public static class UpdateProductEndpoint +{ + [WolverinePut("/products")] + public static Product Put(UpdateProduct command, [Entity(Required = true)] Product product, IDocumentSession session) + { + product.Name = command.Name; + product.Category = command.Category; + product.Description = command.Description; + product.ImageFile = command.ImageFile; + product.Price = command.Price; + + session.Store(product); + return product; + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Discount/Coupon.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Discount/Coupon.cs new file mode 100644 index 000000000..b75cf11d9 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Discount/Coupon.cs @@ -0,0 +1,9 @@ +namespace Discount; + +public class Coupon +{ + public Guid Id { get; set; } + public string ProductName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Amount { get; set; } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Discount/CouponEndpoints.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Discount/CouponEndpoints.cs new file mode 100644 index 000000000..b070ac41d --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Discount/CouponEndpoints.cs @@ -0,0 +1,57 @@ +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Discount; + +public record CreateCoupon(string ProductName, string Description, decimal Amount); +public record UpdateCoupon(Guid Id, string ProductName, string Description, decimal Amount); + +public static class GetCouponEndpoint +{ + [WolverineGet("/discounts/{productName}")] + public static async Task Get(string productName, IQuerySession session, CancellationToken ct) + => await session.Query().FirstOrDefaultAsync(c => c.ProductName == productName, ct); +} + +public static class CreateCouponEndpoint +{ + // Created rather than a bare Coupon — see docs/sample-wiring.md footgun 3 for why it is + // returned directly and not as a (Coupon, IResult) tuple. + [WolverinePost("/discounts")] + public static Created Post(CreateCoupon command, IDocumentSession session) + { + var coupon = new Coupon + { + Id = Guid.NewGuid(), + ProductName = command.ProductName, + Description = command.Description, + Amount = command.Amount, + }; + session.Store(coupon); + return TypedResults.Created($"/discounts/{coupon.ProductName}", coupon); + } +} + +public static class UpdateCouponEndpoint +{ + [WolverinePut("/discounts")] + public static Coupon Put(UpdateCoupon command, [Entity(Required = true)] Coupon coupon, IDocumentSession session) + { + coupon.ProductName = command.ProductName; + coupon.Description = command.Description; + coupon.Amount = command.Amount; + session.Store(coupon); + return coupon; + } +} + +public static class DeleteCouponEndpoint +{ + [WolverineDelete("/discounts/{id}")] + public static void Delete(Guid id, [Entity(Required = true)] Coupon coupon, IDocumentSession session) + { + session.Delete(coupon); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/EcommerceModularMonolith.csproj b/src/Samples/Bobcat/EcommerceModularMonolith/EcommerceModularMonolith.csproj new file mode 100644 index 000000000..a74d3a5f6 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/EcommerceModularMonolith.csproj @@ -0,0 +1,33 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/EcommerceModularMonolith.slnx b/src/Samples/Bobcat/EcommerceModularMonolith/EcommerceModularMonolith.slnx new file mode 100644 index 000000000..ec2a1a6c7 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/EcommerceModularMonolith.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/IntegrationEvents.cs b/src/Samples/Bobcat/EcommerceModularMonolith/IntegrationEvents.cs new file mode 100644 index 000000000..f33d13c46 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/IntegrationEvents.cs @@ -0,0 +1,26 @@ +namespace Shared; + +/// +/// Integration event published by Basket service when checkout completes. +/// Consumed by the Ordering service to create a new order. +/// Replaces MassTransit's BasketCheckoutEvent + BuildingBlocks.Messaging. +/// +public record BasketCheckoutEvent( + string UserName, + Guid CustomerId, + decimal TotalPrice, + // Shipping + string FirstName, + string LastName, + string EmailAddress, + string AddressLine, + string Country, + string State, + string ZipCode, + // Payment + string CardName, + string CardNumber, + string Expiration, + string CVV, + int PaymentMethod +); diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/BasketCheckoutEventHandler.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/BasketCheckoutEventHandler.cs new file mode 100644 index 000000000..7bd8a29e5 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/BasketCheckoutEventHandler.cs @@ -0,0 +1,41 @@ +using Marten; +using Shared; + +namespace Ordering; + +/// +/// Handles BasketCheckoutEvent from the Basket service. +/// Replaces MassTransit IConsumer + MediatR CreateOrderCommand roundtrip. +/// The original had: MassTransit consumer → MediatR Send → CreateOrderHandler → EF Core. +/// Now it's a single Wolverine handler that creates the Order directly. +/// +public static class BasketCheckoutEventHandler +{ + public static void Handle(BasketCheckoutEvent message, IDocumentSession session) + { + var order = new Order + { + Id = Guid.NewGuid(), + CustomerId = message.CustomerId, + OrderName = $"Order-{message.UserName}-{DateTime.UtcNow.Ticks}", + FirstName = message.FirstName, + LastName = message.LastName, + EmailAddress = message.EmailAddress, + AddressLine = message.AddressLine, + Country = message.Country, + State = message.State, + ZipCode = message.ZipCode, + CardName = message.CardName, + CardNumber = message.CardNumber, + Expiration = message.Expiration, + CVV = message.CVV, + PaymentMethod = message.PaymentMethod, + Status = OrderStatus.Pending, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(order); + + Console.WriteLine($"[OrderCreated] Order {order.Id} created for customer {message.CustomerId} (total: {message.TotalPrice:C})"); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/CreateOrder.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/CreateOrder.cs new file mode 100644 index 000000000..6244f6e87 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/CreateOrder.cs @@ -0,0 +1,69 @@ +using FluentValidation; +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Wolverine.Http; + +namespace Ordering; + +public record CreateOrder( + Guid CustomerId, + string OrderName, + string FirstName, + string LastName, + string EmailAddress, + string AddressLine, + string Country, + string State, + string ZipCode, + string CardName, + string CardNumber, + string Expiration, + string CVV, + int PaymentMethod, + List Items +) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.OrderName).NotEmpty(); + RuleFor(x => x.CustomerId).NotEmpty(); + RuleFor(x => x.Items).NotEmpty(); + } + } +} + +public static class CreateOrderEndpoint +{ + // Created rather than a bare Order, matching the other modules' create endpoints. See + // docs/sample-wiring.md footgun 3 for why it is not a (Order, IResult) tuple. + [WolverinePost("/orders")] + public static Created Post(CreateOrder command, IDocumentSession session) + { + var order = new Order + { + Id = Guid.NewGuid(), + CustomerId = command.CustomerId, + OrderName = command.OrderName, + FirstName = command.FirstName, + LastName = command.LastName, + EmailAddress = command.EmailAddress, + AddressLine = command.AddressLine, + Country = command.Country, + State = command.State, + ZipCode = command.ZipCode, + CardName = command.CardName, + CardNumber = command.CardNumber, + Expiration = command.Expiration, + CVV = command.CVV, + PaymentMethod = command.PaymentMethod, + Status = OrderStatus.Pending, + Items = command.Items, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(order); + return TypedResults.Created($"/orders/{order.Id}", order); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/DeleteOrder.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/DeleteOrder.cs new file mode 100644 index 000000000..22cf8bd6d --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/DeleteOrder.cs @@ -0,0 +1,14 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Ordering; + +public static class DeleteOrderEndpoint +{ + [WolverineDelete("/orders/{id}")] + public static void Delete(Guid id, [Entity(Required = true)] Order order, IDocumentSession session) + { + session.Delete(order); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/GetOrders.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/GetOrders.cs new file mode 100644 index 000000000..6ce2ddeeb --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/GetOrders.cs @@ -0,0 +1,40 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Ordering; + +public static class GetOrdersEndpoint +{ + [WolverineGet("/orders")] + public static async Task> Get(IQuerySession session, CancellationToken ct) + { + var orders = await session.Query() + .OrderByDescending(o => o.CreatedAt) + .ToListAsync(ct); + + return orders.Select(o => new OrderDto(o.Id, o.CustomerId, o.OrderName, o.TotalPrice, o.Status, o.Items)).ToList(); + } +} + +public static class GetOrderByIdEndpoint +{ + [WolverineGet("/orders/{id}")] + public static OrderDto? Get(Guid id, [Entity] Order? order) + => order is not null + ? new OrderDto(order.Id, order.CustomerId, order.OrderName, order.TotalPrice, order.Status, order.Items) + : null; +} + +public static class GetOrdersByCustomerEndpoint +{ + [WolverineGet("/orders/customer/{customerId}")] + public static async Task> Get(Guid customerId, IQuerySession session, CancellationToken ct) + { + var orders = await session.Query() + .Where(o => o.CustomerId == customerId) + .ToListAsync(ct); + + return orders.Select(o => new OrderDto(o.Id, o.CustomerId, o.OrderName, o.TotalPrice, o.Status, o.Items)).ToList(); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/Order.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/Order.cs new file mode 100644 index 000000000..9b74b685b --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Ordering/Order.cs @@ -0,0 +1,66 @@ +namespace Ordering; + +/// +/// Order document stored in Marten. Replaces the DDD aggregate with +/// ValueObjects (OrderId, CustomerId, Address, Payment) and the separate +/// Customer, Product, OrderItem entities from 4 EF Core configuration classes. +/// Marten stores this as a single JSON document — no joins needed. +/// +public class Order +{ + public Guid Id { get; set; } + public Guid CustomerId { get; set; } + public string OrderName { get; set; } = string.Empty; + + // Shipping address (was Address ValueObject) + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; + public string AddressLine { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + + // Payment (was Payment ValueObject) + public string CardName { get; set; } = string.Empty; + public string CardNumber { get; set; } = string.Empty; + public string Expiration { get; set; } = string.Empty; + public string CVV { get; set; } = string.Empty; + public int PaymentMethod { get; set; } + + public OrderStatus Status { get; set; } = OrderStatus.Pending; + public List Items { get; set; } = []; + public decimal TotalPrice => Items.Sum(i => i.Price * i.Quantity); + + // Audit + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } +} + +public class OrderItem +{ + public Guid ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public int Quantity { get; set; } + public decimal Price { get; set; } +} + +public enum OrderStatus +{ + Draft, + Pending, + Completed, + Cancelled, +} + +/// +/// DTO for API responses — keeps internal document shape private. +/// +public record OrderDto( + Guid Id, + Guid CustomerId, + string OrderName, + decimal TotalPrice, + OrderStatus Status, + IReadOnlyList Items +); diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Program.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Program.cs new file mode 100644 index 000000000..dd28d9a5d --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Program.cs @@ -0,0 +1,94 @@ +using Wolverine.FluentValidation; +using Marten; +using Wolverine; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=ecommerce;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + + // Each module gets its own schema — clean separation within one database + opts.Schema.For().DatabaseSchemaName("catalog"); + opts.Schema.For().DatabaseSchemaName("basket").Identity(x => x.Id); + opts.Schema.For().DatabaseSchemaName("ordering"); + opts.Schema.For().Index(x => x.CustomerId); + opts.Schema.For().Index(x => x.OrderName); + opts.Schema.For().DatabaseSchemaName("discount"); + opts.Schema.For().Index(x => x.ProductName); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); + opts.ServiceName = "EcommerceMonolith"; + + // Inter-module messaging uses durable local queues — + // messages survive process restarts via the Marten outbox + opts.LocalQueue("basket-checkout").UseDurableInbox(); + opts.LocalQueue("order-notifications").UseDurableInbox(); + + // Route BasketCheckoutEvent to the durable local queue + opts.Publish(x => + { + x.Message(); + x.ToLocalQueue("basket-checkout"); + }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +await SeedData(app); + +await app.RunAsync(); + +static async Task SeedData(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + + if (!await session.Query().AnyAsync()) + { + session.Store( + new Catalog.Product { Name = "IPhone X", Category = ["Smart Phone"], Description = "Apple smartphone", ImageFile = "product-1.png", Price = 950.00m }, + new Catalog.Product { Name = "Samsung 10", Category = ["Smart Phone"], Description = "Samsung smartphone", ImageFile = "product-2.png", Price = 840.00m }, + new Catalog.Product { Name = "Huawei Plus", Category = ["White Appliances"], Description = "Huawei phone", ImageFile = "product-3.png", Price = 650.00m } + ); + } + + if (!await session.Query().AnyAsync()) + { + session.Store( + new Discount.Coupon { ProductName = "IPhone X", Description = "IPhone Discount", Amount = 150 }, + new Discount.Coupon { ProductName = "Samsung 10", Description = "Samsung Discount", Amount = 100 } + ); + } + + await session.SaveChangesAsync(); +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/README.md b/src/Samples/Bobcat/EcommerceModularMonolith/README.md new file mode 100644 index 000000000..20194a4a8 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/README.md @@ -0,0 +1,62 @@ +# E-Commerce Modular Monolith — Critter Stack Conversion + +> **See also:** `EcommerceMicroservices/` — the same domain split into separate services communicating via RabbitMQ. + +## Original Project + +**Repository:** [aspnetrun/run-aspnetcore-microservices](https://github.com/aspnetrun/run-aspnetcore-microservices) +**License:** MIT + +Same domain as EcommerceMicroservices, but collapsed into a single application demonstrating the modular monolith pattern with Wolverine + Marten. + +## Architecture + +One host, one PostgreSQL database, four modules separated by folder and Marten schema: + +``` +EcommerceModularMonolith/ + Program.cs ← single host, schema-per-module Marten config + IntegrationEvents.cs ← shared messages between modules + Catalog/ ← Product CRUD + Basket/ ← ShoppingCart CRUD, checkout publishes BasketCheckoutEvent + Ordering/ ← Order CRUD, handles BasketCheckoutEvent + Discount/ ← Coupon CRUD +``` + +### Module isolation + +Each module has its own Marten schema (`catalog`, `basket`, `ordering`, `discount`) within the same PostgreSQL database. Modules communicate through **durable local queues** — messages are persisted via the Marten outbox and survive process restarts. + +```csharp +// Durable local queues for inter-module messaging +opts.LocalQueue("basket-checkout").UseDurableInbox(); + +// Route integration events to local queues +opts.Publish(x => +{ + x.Message(); + x.ToLocalQueue("basket-checkout"); +}); +``` + +### Microservices vs Modular Monolith + +| Aspect | EcommerceMicroservices | EcommerceModularMonolith | +|--------|----------------------|------------------------| +| Deployment | 4 separate processes | 1 process | +| Database | 4 PostgreSQL databases | 1 database, 4 schemas | +| Transport | RabbitMQ | Durable local queues | +| Handler code | Identical | Identical | +| Infrastructure | Docker Compose + RabbitMQ | PostgreSQL only | + +The handler code is the same in both versions — only `Program.cs` and the transport configuration differ. This demonstrates Wolverine's transport abstraction: swap local queues for RabbitMQ when you're ready to split into services. + +## Running + +Requires PostgreSQL only (no RabbitMQ needed). + +```bash +dotnet run +``` + +Swagger UI at `/swagger`. diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..fd2f93d69 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/AssemblyAttributes.cs @@ -0,0 +1,24 @@ +using EcommerceModularMonolith.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(EcommerceModularMonolithHost))] + +// Every spec empties the store before it runs (see EcommerceModularMonolithSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tells ASP.NET Core's WebApplicationFactory (which Alba uses) where the host's content root +// is. Without it, discovery walks up from the test assembly's bin directory and synthesizes +// `samples/EcommerceModularMonolith/EcommerceModularMonolith/` from the host assembly name — a +// folder that does not exist — and throws DirectoryNotFoundException. The Tests project lives +// INSIDE the host project rather than beside it, which is not the layout the default assumes. +// +// The path is relative to the test assembly's bin directory at run time: from +// `samples/EcommerceModularMonolith/Tests/bin//net10.0/`, four levels up is +// `samples/EcommerceModularMonolith/`, where the host's appsettings.json lives. +[assembly: WebApplicationFactoryContentRoot( + "EcommerceModularMonolith", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/BasketSpecs.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/BasketSpecs.cs new file mode 100644 index 000000000..07b18c1f7 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/BasketSpecs.cs @@ -0,0 +1,59 @@ +namespace EcommerceModularMonolith.Tests; + +[BobcatFeature("Ecommerce Modular Monolith: basket")] +public class BasketSpecs(EcommerceModularMonolithHost app) : EcommerceModularMonolithSpec(app) +{ + [BobcatSpec] + public async Task store_basket() + { + await GivenACatalogProduct("BasketItem", 10.00m); + + await WhenABasketIsStored("customer1"); + + ThenTheResponseStatusIs(201); + } + + [BobcatSpec] + public async Task get_basket() + { + await GivenACatalogProduct("GetBasketItem", 10.00m); + await GivenABasket("getBasketCustomer"); + + await WhenTheBasketIsRead("getBasketCustomer"); + + ThenTheResponseStatusIs(200); + ThenTheBasketTotalIs(10.00m); + } + + [BobcatSpec] + public async Task delete_basket() + { + await GivenACatalogProduct("DelBasketItem", 10.00m); + await GivenABasket("delBasketCustomer"); + + await WhenTheBasketIsDeleted("delBasketCustomer"); + + ThenTheResponseStatusIs(204); + await ThenTheBasketIsGone("delBasketCustomer"); + } + + [BobcatSpec] + public async Task checkout_basket() + { + await GivenACatalogProduct("CheckoutItem", 25.00m); + await GivenABasket("checkoutCustomer"); + + await WhenTheBasketIsCheckedOut("checkoutCustomer"); + + ThenTheResponseStatusIs(202); + await ThenTheBasketIsGone("checkoutCustomer"); + } + + [BobcatSpec] + public async Task checkout_a_basket_that_does_not_exist() + { + await WhenTheBasketIsCheckedOut("nobody"); + + ThenTheResponseStatusIs(404); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/CatalogSpecs.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/CatalogSpecs.cs new file mode 100644 index 000000000..58ae8f5c5 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/CatalogSpecs.cs @@ -0,0 +1,66 @@ +namespace EcommerceModularMonolith.Tests; + +[BobcatFeature("Ecommerce Modular Monolith: catalog")] +public class CatalogSpecs(EcommerceModularMonolithHost app) : EcommerceModularMonolithSpec(app) +{ + [BobcatSpec] + public async Task create_catalog_product() + { + await WhenACatalogProductIsCreated("Widget", 9.99m); + + ThenTheResponseStatusIs(201); + ThenTheCatalogProductIdIsReturned(); + await ThenTheStoredCatalogProductIs("Widget", 9.99m); + } + + [BobcatSpec] + public async Task get_catalog_products() + { + await GivenACatalogProduct("Gadget", 19.99m); + + await WhenAllCatalogProductsAreRead(); + + ThenTheResponseStatusIs(200); + ThenAtLeastCatalogProductsAreReturned(1); + } + + [BobcatSpec] + public async Task get_catalog_product_by_id() + { + await GivenACatalogProduct("Gizmo", 5.99m); + + await WhenTheCatalogProductIsRead(); + + ThenTheResponseStatusIs(200); + } + + [BobcatSpec] + public async Task get_catalog_product_by_id_404() + { + await WhenTheCatalogProductWithIdIsRead(Guid.Empty); + + ThenTheResponseStatusIs(404); + } + + [BobcatSpec] + public async Task update_catalog_product() + { + await GivenACatalogProduct("OldWidget", 1.00m); + + await WhenTheCatalogProductIsRenamed("NewWidget"); + + ThenTheResponseStatusIs(200); + await ThenTheStoredCatalogProductIs("NewWidget", 1.00m); + } + + [BobcatSpec] + public async Task delete_catalog_product() + { + await GivenACatalogProduct("DeleteMe", 1.00m); + + await WhenTheCatalogProductIsDeleted(); + + ThenTheResponseStatusIs(204); + await ThenTheCatalogProductIsGone(); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/DiscountSpecs.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/DiscountSpecs.cs new file mode 100644 index 000000000..5524c5cae --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/DiscountSpecs.cs @@ -0,0 +1,45 @@ +namespace EcommerceModularMonolith.Tests; + +[BobcatFeature("Ecommerce Modular Monolith: discount")] +public class DiscountSpecs(EcommerceModularMonolithHost app) : EcommerceModularMonolithSpec(app) +{ + [BobcatSpec] + public async Task create_a_discount() + { + await WhenADiscountIsCreated("DiscountedProduct", 10); + + ThenTheResponseStatusIs(201); + } + + [BobcatSpec] + public async Task get_discount_by_product_name() + { + await GivenADiscount("SearchedProduct", 15); + + await WhenTheDiscountIsRead("SearchedProduct"); + + ThenTheResponseStatusIs(200); + ThenTheDiscountAmountIs(15); + } + + [BobcatSpec] + public async Task update_a_discount() + { + await GivenADiscount("UpdatableProduct", 5); + + await WhenTheDiscountIsChanged(20); + + ThenTheResponseStatusIs(200); + await ThenTheStoredDiscountIs("UpdatableProduct", 20); + } + + [BobcatSpec] + public async Task delete_a_discount() + { + await GivenADiscount("DeletableDiscount", 10); + + await WhenTheDiscountIsDeleted(); + + ThenTheResponseStatusIs(204); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/EcommerceModularMonolithHost.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/EcommerceModularMonolithHost.cs new file mode 100644 index 000000000..ad32afadd --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/EcommerceModularMonolithHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace EcommerceModularMonolith.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class EcommerceModularMonolithHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/EcommerceModularMonolithSpec.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/EcommerceModularMonolithSpec.cs new file mode 100644 index 000000000..f2cfd96e8 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/EcommerceModularMonolithSpec.cs @@ -0,0 +1,358 @@ +using Alba; +using Basket; +using Bobcat.CritterStack; +using Catalog; +using Discount; +using Ordering; +using Wolverine; +using Wolverine.Tracking; + +namespace EcommerceModularMonolith.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every EcommerceModularMonolith spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// The status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a spec +/// asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +public abstract class EcommerceModularMonolithSpec(EcommerceModularMonolithHost app) : IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // Empties every module's schema, and removes the host's seed data (three products, two coupons) so "at least 1 catalog product" means the one the spec created. Load-bearing for the ordering specs, which would otherwise find orders earlier runs left behind. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } + + /// + /// Run an HTTP call and wait for every message it cascades to be fully handled. The modules in this + /// sample talk over UseDurableInbox() local queues, so the response goes out before the next + /// module has handled the cascade; asserting straight off it would race the handler. + /// + protected async Task> TrackedAsync(Func>> call) + { + HttpResult? captured = null; + + // Explicitly typed: ExecuteAndWaitAsync overloads on Task and ValueTask, and an async lambda is + // convertible to both. + Func act = async _ => { captured = await call(); }; + + await Alba.TrackActivity() + .Timeout(TimeSpan.FromSeconds(30)) + .ExecuteAndWaitAsync(act); + + return captured!; + } + + // ---- the Ecommerce vocabulary ------------------------------------------------------------ + // + // Shared by the four module spec classes. Reuses the host's own command and document types via the + // project reference rather than re-declaring request records locally. + + private Guid _productId; + private string _productName = string.Empty; + private decimal _productPrice; + private Guid _couponId; + private string _couponProductName = string.Empty; + private Guid _orderId; + private int _lastStatusCode; + private ShoppingCart? _basket; + private Coupon? _coupon; + private List _products = []; + private List _orders = []; + + // The checkout command wants a customer Guid, which the specs never name -- the basket is keyed by + // user name. One customer per spec is what ties "the order created by the checkout" back to the + // checkout that created it. + private readonly Guid _customerId = Guid.NewGuid(); + + // ---- catalog ---------------------------------------------------------------------------- + + [Given("a catalog product named \"{name}\" priced {price}")] + public Task GivenACatalogProduct(string name, decimal price) => createProduct(name, price); + + [When("a catalog product named \"{name}\" priced {price} is created")] + public Task WhenACatalogProductIsCreated(string name, decimal price) => createProduct(name, price); + + private async Task createProduct(string name, decimal price) + { + // Category is required by the command's validator; the specs do not care which. + var result = await PostJsonAsync( + "/products", new CreateProduct(name, ["Specs"], $"{name} description", $"{name}.png", price)); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) + { + _productId = result.Body.Id; + _productName = result.Body.Name; + _productPrice = result.Body.Price; + } + } + + [When("all catalog products are read")] + public async Task WhenAllCatalogProductsAreRead() + { + var result = await GetJsonAsync>("/products"); + _lastStatusCode = result.StatusCode; + _products = result.Body ?? []; + } + + [When("the catalog product is read by id")] + public Task WhenTheCatalogProductIsRead() => readProduct(_productId); + + [When("the catalog product with id {id} is read")] + public Task WhenTheCatalogProductWithIdIsRead(Guid id) => readProduct(id); + + private async Task readProduct(Guid id) + { + var result = await GetJsonAsync($"/products/{id}"); + _lastStatusCode = result.StatusCode; + } + + [When("the catalog product is renamed \"{newName}\"")] + public async Task WhenTheCatalogProductIsRenamed(string newName) + { + // PUT /products with the id in the body; [Entity] loads the Product from the command's Id. The + // other fields are replayed from the create, because the command replaces the whole document. + var result = await PutJsonAsync( + "/products", + new UpdateProduct(_productId, newName, ["Specs"], $"{newName} description", $"{newName}.png", _productPrice)); + + _lastStatusCode = result.StatusCode; + } + + [When("the catalog product is deleted")] + public async Task WhenTheCatalogProductIsDeleted() + { + var result = await DeleteAsync($"/products/{_productId}"); + _lastStatusCode = result.StatusCode; + } + + [Then("the catalog product id is returned")] + public void ThenTheCatalogProductIdIsReturned() => SpecAssert.Fact(_productId != Guid.Empty, "No product id came back."); + + /// Reads the product back, so an update that answered 200 without persisting still fails. + [Then("the stored catalog product is named \"{name}\" priced {price}")] + public async Task ThenTheStoredCatalogProductIs(string name, decimal price) + { + var product = (await GetJsonAsync($"/products/{_productId}")).Body; + SpecAssert.Check("name", product?.Name, name); + SpecAssert.Check("price", product?.Price, price); + } + + [Then("at least {count} catalog product is returned")] + public void ThenAtLeastCatalogProductsAreReturned(int count) + => SpecAssert.Fact(_products.Count >= count, $"Expected at least {count} product(s), got {_products.Count}."); + + [Then("the catalog product is gone")] + public async Task ThenTheCatalogProductIsGone() + => SpecAssert.Check("status", (await GetJsonAsync($"/products/{_productId}")).StatusCode, 404); + + // ---- basket ----------------------------------------------------------------------------- + + [Given("a basket stored for customer \"{userName}\" with the product")] + public Task GivenABasket(string userName) => storeBasket(userName); + + [When("a basket is stored for customer \"{userName}\" with the product")] + public Task WhenABasketIsStored(string userName) => storeBasket(userName); + + private async Task storeBasket(string userName) + { + // The host's own ShoppingCart document, keyed by user name, holding the product the spec just + // created at the price it was created with. + var cart = new ShoppingCart + { + Id = userName, + Items = + [ + new ShoppingCartItem + { + ProductId = _productId, + ProductName = _productName, + Quantity = 1, + Price = _productPrice, + Color = "Black", + }, + ], + }; + + var result = await PostJsonAsync("/basket", new StoreBasket(cart)); + _lastStatusCode = result.StatusCode; + _basket = result.Body; + } + + [When("the basket for customer \"{userName}\" is read")] + public async Task WhenTheBasketIsRead(string userName) + { + var result = await GetJsonAsync($"/basket/{userName}"); + _lastStatusCode = result.StatusCode; + _basket = result.Body; + } + + [When("the basket for customer \"{userName}\" is deleted")] + public async Task WhenTheBasketIsDeleted(string userName) + { + var result = await DeleteAsync($"/basket/{userName}"); + _lastStatusCode = result.StatusCode; + } + + [Given("the basket for customer \"{userName}\" was checked out")] + public Task GivenTheBasketWasCheckedOut(string userName) => checkout(userName); + + [When("the basket for customer \"{userName}\" is checked out")] + public Task WhenTheBasketIsCheckedOut(string userName) => checkout(userName); + + private async Task checkout(string userName) + { + var command = new CheckoutBasket( + userName, + _customerId, + "Jane", "Doe", "jane@example.com", "1 Main St", "US", "TX", "75001", + "Jane Doe", "4111111111111111", "12/30", "123", PaymentMethod: 1); + + // Checkout returns 202 before the Ordering module has handled BasketCheckoutEvent, so the call is + // tracked until the cascade is fully handled. + var result = await TrackedAsync(() => PostJsonAsync("/basket/checkout", command)); + _lastStatusCode = result.StatusCode; + } + + [Then("the basket total is {total}")] + public void ThenTheBasketTotalIs(decimal total) => SpecAssert.Check("total", _basket?.TotalPrice, total); + + [Then("the basket for customer \"{userName}\" is gone")] + public async Task ThenTheBasketIsGone(string userName) + => SpecAssert.Check("status", (await GetJsonAsync($"/basket/{userName}")).StatusCode, 404); + + // ---- ordering --------------------------------------------------------------------------- + + [When("all orders are read")] + public async Task WhenAllOrdersAreRead() + { + var result = await GetJsonAsync>("/orders"); + _lastStatusCode = result.StatusCode; + _orders = result.Body ?? []; + } + + [When("the order created by the checkout is read")] + public async Task WhenTheCheckoutOrderIsRead() + { + await locateCheckoutOrder(); + var result = await GetJsonAsync($"/orders/{_orderId}"); + _lastStatusCode = result.StatusCode; + } + + [When("the order created by the checkout is deleted")] + public async Task WhenTheCheckoutOrderIsDeleted() + { + await locateCheckoutOrder(); + var result = await DeleteAsync($"/orders/{_orderId}"); + _lastStatusCode = result.StatusCode; + } + + // Looked up by customer rather than "the first order in the list", so the step cannot pick up an + // order another spec, or another run, left behind. + private async Task locateCheckoutOrder() + => _orderId = (await GetJsonAsync>($"/orders/customer/{_customerId}")).Body?.FirstOrDefault()?.Id ?? Guid.Empty; + + [Then("at least {count} order is returned")] + public void ThenAtLeastOrdersAreReturned(int count) + => SpecAssert.Fact(_orders.Count >= count, $"Expected at least {count} order(s), got {_orders.Count}."); + + /// + /// The sample's central claim: a checkout in the Basket module becomes an order in the Ordering module, + /// carried over a durable local queue. The customer id is the thread that ties the two together. + /// + [Then("an order exists for the checked-out customer")] + public void ThenAnOrderExistsForTheCheckedOutCustomer() + => SpecAssert.Fact(_orders.Any(o => o.CustomerId == _customerId), $"No order for customer {_customerId}."); + + // ---- discount --------------------------------------------------------------------------- + + [Given("a discount for product \"{productName}\" of {amount}")] + public Task GivenADiscount(string productName, decimal amount) => createDiscount(productName, amount); + + [When("a discount for product \"{productName}\" of {amount} is created")] + public Task WhenADiscountIsCreated(string productName, decimal amount) => createDiscount(productName, amount); + + private async Task createDiscount(string productName, decimal amount) + { + var result = await PostJsonAsync( + "/discounts", new CreateCoupon(productName, $"{productName} discount", amount)); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) + { + _couponId = result.Body.Id; + _couponProductName = result.Body.ProductName; + } + } + + [When("the discount for product \"{productName}\" is read")] + public async Task WhenTheDiscountIsRead(string productName) + { + var result = await GetJsonAsync($"/discounts/{productName}"); + _lastStatusCode = result.StatusCode; + _coupon = result.Body; + } + + [When("the discount is changed to {amount}")] + public async Task WhenTheDiscountIsChanged(decimal amount) + { + // PUT /discounts with the id in the body; [Entity] loads the Coupon from UpdateCoupon.Id. + var result = await PutJsonAsync( + "/discounts", new UpdateCoupon(_couponId, _couponProductName, $"{_couponProductName} discount", amount)); + + _lastStatusCode = result.StatusCode; + } + + [When("the discount is deleted")] + public async Task WhenTheDiscountIsDeleted() + { + var result = await DeleteAsync($"/discounts/{_couponId}"); + _lastStatusCode = result.StatusCode; + } + + [Then("the discount amount is {amount}")] + public void ThenTheDiscountAmountIs(decimal amount) => SpecAssert.Check("amount", _coupon?.Amount, amount); + + [Then("the stored discount for product \"{productName}\" has amount {amount}")] + public async Task ThenTheStoredDiscountIs(string productName, decimal amount) + => SpecAssert.Check("amount", (await GetJsonAsync($"/discounts/{productName}")).Body?.Amount, amount); + + // ---- shared ----------------------------------------------------------------------------- + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/OrderingSpecs.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/OrderingSpecs.cs new file mode 100644 index 000000000..413cb94fb --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/OrderingSpecs.cs @@ -0,0 +1,54 @@ +namespace EcommerceModularMonolith.Tests; + +/// +/// A checkout in the Basket module becomes an order in the Ordering module, carried over a durable local +/// queue -- so every spec here arranges its order through a checkout rather than storing one directly. +/// +[BobcatFeature("Ecommerce Modular Monolith: ordering")] +public class OrderingSpecs(EcommerceModularMonolithHost app) : EcommerceModularMonolithSpec(app) +{ + [BobcatSpec] + public async Task create_an_order() + { + await GivenACatalogProduct("OrderItem", 15.00m); + await GivenABasket("orderCustomer"); + await GivenTheBasketWasCheckedOut("orderCustomer"); + + await WhenAllOrdersAreRead(); + + ThenAtLeastOrdersAreReturned(1); + ThenAnOrderExistsForTheCheckedOutCustomer(); + } + + [BobcatSpec] + public async Task get_all_orders() + { + await WhenAllOrdersAreRead(); + + ThenTheResponseStatusIs(200); + } + + [BobcatSpec] + public async Task get_order_by_id() + { + await GivenACatalogProduct("OrderByIdItem", 15.00m); + await GivenABasket("orderByIdCustomer"); + await GivenTheBasketWasCheckedOut("orderByIdCustomer"); + + await WhenTheCheckoutOrderIsRead(); + + ThenTheResponseStatusIs(200); + } + + [BobcatSpec] + public async Task delete_an_order() + { + await GivenACatalogProduct("DeleteOrderItem", 15.00m); + await GivenABasket("deleteOrderCustomer"); + await GivenTheBasketWasCheckedOut("deleteOrderCustomer"); + + await WhenTheCheckoutOrderIsDeleted(); + + ThenTheResponseStatusIs(204); + } +} diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/Tests.csproj b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/Tests.csproj new file mode 100644 index 000000000..a06e3bc0e --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + EcommerceModularMonolith.Tests + EcommerceModularMonolith.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/Tests/Usings.cs b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/Usings.cs new file mode 100644 index 000000000..c17dbd16f --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/Tests/Usings.cs @@ -0,0 +1,3 @@ +global using Bobcat; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/EcommerceModularMonolith/appsettings.json b/src/Samples/Bobcat/EcommerceModularMonolith/appsettings.json new file mode 100644 index 000000000..8d61991b3 --- /dev/null +++ b/src/Samples/Bobcat/EcommerceModularMonolith/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=ecommerce;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/LICENSE b/src/Samples/Bobcat/LICENSE new file mode 100644 index 000000000..a0bf8788a --- /dev/null +++ b/src/Samples/Bobcat/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 The "Critter Stack" and JasperFx Tools + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/AspireHost.csproj b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/AspireHost.csproj new file mode 100644 index 000000000..9d152b091 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/AspireHost.csproj @@ -0,0 +1,25 @@ + + + + + + Exe + net9.0 + enable + enable + true + 2B9BDECF-73FC-4806-B7D4-120DC107489E + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/Program.cs b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/Program.cs new file mode 100644 index 000000000..667916876 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/Program.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var postgresdb = builder.AddPostgres("marten"); + +builder.AddProject("publisher") + .WithReference(postgresdb); + +builder.AddProject("trip-building") + .WithReference(postgresdb); + +builder.Build().Run(); \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/Properties/launchSettings.json b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/Properties/launchSettings.json new file mode 100644 index 000000000..f22d9022d --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/Properties/launchSettings.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15242", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19076", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20101", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/appsettings.Development.json b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/appsettings.json b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/appsettings.json new file mode 100644 index 000000000..90dbe6f19 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/AspireHost/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "ConnectionStrings": { + "marten": "Host=localhost;Port=5432;Database=marten_testing;Username=postgres;password=postgres;Command Timeout=5" + } +} diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/EventPublisher.csproj b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/EventPublisher.csproj new file mode 100644 index 000000000..1415f673d --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/EventPublisher.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/HostedPublisher.cs b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/HostedPublisher.cs new file mode 100644 index 000000000..06ccfddce --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/HostedPublisher.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.Metrics; +using Marten; +using Microsoft.Extensions.Hosting; + +namespace EventPublisher; + +internal class HostedPublisher : BackgroundService +{ + private readonly IDocumentStore _store; + + public HostedPublisher(IDocumentStore store) + { + _store = store; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var tasks = new List(); + + var databases = await _store.Storage.AllDatabases(); + foreach (var database in databases) + { + for (var i = 0; i < 10; i++) + { + var publisher = new Publisher(_store, database, stoppingToken); + tasks.Add(publisher.Start()); + } + } + + await Task.WhenAll(tasks.ToArray()); + } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/Program.cs b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/Program.cs new file mode 100644 index 000000000..b9c44e8aa --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/Program.cs @@ -0,0 +1,53 @@ +using EventPublisher; +using JasperFx.OpenTelemetry; +using Marten; +using Marten.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; + +var builder = Host.CreateApplicationBuilder(); + +// Register the NpgsqlDataSource in the IoC container using +// connection string named "marten" from IConfiguration +builder.AddNpgsqlDataSource("marten"); + +builder.Logging.AddConsole(); + +builder.Logging.AddOpenTelemetry(logging => +{ + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; +}); + +// The following lines enable exporting Open Telemetry data to an Otlp collector +// Project Aspire pushes in the Uri for the collection through an Environment +// variable if you're wondering like I did where the "magic" comes from +builder.Services.AddOpenTelemetry() + .UseOtlpExporter() + + // Enable exports of Open Telemetry activities for Marten + .WithTracing(tracing => { tracing.AddSource("Marten"); }) + + // Enable exports of metrics for Marten + .WithMetrics(metrics => { metrics.AddMeter("Marten"); }); + +builder.Services.AddHostedService(); + +builder.Services.AddMarten(opts => + { + opts.DatabaseSchemaName = "cli"; + + // Turn on Otel tracing for connection activity, and + // also tag events to each span for all the Marten "write" + // operations + opts.OpenTelemetry.TrackConnections = TrackLevel.Verbose; + + opts.OpenTelemetry.TrackEventCounters(); + }) + + // Use PostgreSQL data source from the IoC container + .UseNpgsqlDataSource(); + +builder.Build().Run(); \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/Publisher.cs b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/Publisher.cs new file mode 100644 index 000000000..bab41525c --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/Publisher.cs @@ -0,0 +1,66 @@ +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Marten; +using Marten.Services; +using Marten.Storage; +using TripDomain; + +namespace EventPublisher; + +internal class Publisher +{ + private readonly IMartenDatabase _database; + private readonly CancellationToken _stoppingToken; + private readonly string _name; + private readonly IDocumentStore _store; + + public Publisher(IDocumentStore store, IMartenDatabase database, CancellationToken stoppingToken) + { + _store = store; + _database = database; + _stoppingToken = stoppingToken; + + var storeName = store.GetType() == typeof(DocumentStore) ? "Marten" : store.GetType().NameInCode(); + _name = $"{storeName}:{_database.Identifier}"; + } + + public Task Start() + { + var random = Random.Shared; + return Task.Run(async () => + { + while (!_stoppingToken.IsCancellationRequested) + { + var delay = random.Next(0, 250); + + await Task.Delay(delay.Milliseconds(), _stoppingToken); + await PublishEvents(); + } + }, _stoppingToken); + } + + public async Task PublishEvents() + { + var streams = TripStream.RandomStreams(5); + while (streams.Any()) + { + var options = SessionOptions.ForDatabase(_database); + + await using var session = _store.LightweightSession(options); + foreach (var stream in streams.ToArray()) + { + if (stream.TryCheckOutEvents(out var events)) + { + session.Events.Append(stream.StreamId, events); + } + + if (stream.IsFinishedPublishing()) + { + streams.Remove(stream); + } + } + + await session.SaveChangesAsync(_stoppingToken); + } + } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/appsettings.json b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/appsettings.json new file mode 100644 index 000000000..5f00213cd --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/EventPublisher/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning", + "Npgsql": "Error" + } + }, + "ConnectionStrings": { + "marten": "Host=localhost;Port=5432;Database=marten_testing;Username=postgres;password=postgres;Command Timeout=5" + } +} diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/MartenWithProjectAspire.slnx b/src/Samples/Bobcat/MartenWithProjectAspire/MartenWithProjectAspire.slnx new file mode 100644 index 000000000..e7ce30fa5 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/MartenWithProjectAspire.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/README.md b/src/Samples/Bobcat/MartenWithProjectAspire/README.md new file mode 100644 index 000000000..aa2ad953f --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/README.md @@ -0,0 +1,17 @@ +# MartenWithProjectAspire + +This sample project just shows the mechanics of running PostgreSQL and Marten from a Project Aspire runner, and previews the Open Telemetry and metrics support introduced in Marten 7.10 using +the Project Aspire dashboard. + +In this solution, you'll find four projects: + +1. `TripDomain` -- just a shared library of domain types +1. `EventPublisher` -- a headless console application that continuously appends random events representing a `Trip` aggregate +1. `TripBuildingService` -- a headless console application that is running Marten's async daemon to continuously build projected documents from incoming events to the Marten event store +1. `AspireHost` -- a Project Aspire host project that will launch a new PostgreSQL container in Docker, pass the necessary connection string to the other applications, and start up `EventPublisher` and `TripBuildingService` + +To run this sample project, you will first need to: + +* Install [Project Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/setup-tooling) on your box and any dependencies like [Docker Desktop](https://www.docker.com/products/docker-desktop/) +* Run the `AspireHost` project, either within JetBrains Rider / Visual Studio.Net, or through `dotnet run` in that project's root GetPackageDirectory +* Open your browser to the Aspire dashboard at [http://localhost:15242](http://localhost:15242) \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Day.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Day.cs new file mode 100644 index 000000000..584fbb056 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Day.cs @@ -0,0 +1,21 @@ +public class Day +{ + public long Version { get; set; } + + public int Id { get; set; } + + // how many trips started on this day? + public int Started { get; set; } + + // how many trips ended on this day? + public int Ended { get; set; } + + public int Stops { get; set; } + + // how many miles did the active trips + // drive in which direction on this day? + public double North { get; set; } + public double East { get; set; } + public double West { get; set; } + public double South { get; set; } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/DayProjection.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/DayProjection.cs new file mode 100644 index 000000000..4d49a01d8 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/DayProjection.cs @@ -0,0 +1,49 @@ +using Marten.Events.Projections; +using TripDomain; + +public class DayProjection: MultiStreamProjection +{ + public DayProjection() + { + // Tell the projection how to group the events + // by Day document + Identity(x => x.Day); + + // This just lets the projection work independently + // on each Movement child of the Travel event + // as if it were its own event + FanOut(x => x.Movements); + + // You can also access Event data + FanOut(x => x.Data.Stops); + + Name = "Day"; + } + + public void Apply(Day day, TripStarted e) => day.Started++; + public void Apply(Day day, TripEnded e) => day.Ended++; + + public void Apply(Day day, Movement e) + { + switch (e.Direction) + { + case Direction.East: + day.East += e.Distance; + break; + case Direction.North: + day.North += e.Distance; + break; + case Direction.South: + day.South += e.Distance; + break; + case Direction.West: + day.West += e.Distance; + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + public void Apply(Day day, Stop e) => day.Stops++; +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Distance.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Distance.cs new file mode 100644 index 000000000..0ac47ae7a --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Distance.cs @@ -0,0 +1,11 @@ +public class Distance +{ + public Guid Id { get; set; } + public double Total { get; set; } + public int Day { get; set; } + + public override string ToString() + { + return $"{nameof(Id)}: {Id}, {nameof(Total)}: {Total}, {nameof(Day)}: {Day}"; + } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/DistanceProjection.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/DistanceProjection.cs new file mode 100644 index 000000000..cf66128c9 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/DistanceProjection.cs @@ -0,0 +1,17 @@ +using JasperFx.Events; +using Marten.Events.Projections; +using TripDomain; + +public partial class DistanceProjection: EventProjection +{ + public DistanceProjection() + { + Name = "Distance"; + } + + // Create a new Distance document based on a Travel event + public Distance Create(Travel travel, IEvent e) + { + return new Distance {Id = e.Id, Day = travel.Day, Total = travel.TotalDistance()}; + } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Program.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Program.cs new file mode 100644 index 000000000..d5309b2fb --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/Program.cs @@ -0,0 +1,45 @@ +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; + +var builder = Host.CreateApplicationBuilder(); + +// Register the NpgsqlDataSource in the IoC container using +// connection string named "marten" from IConfiguration +builder.AddNpgsqlDataSource("marten"); + +builder.Logging.AddOpenTelemetry(logging => +{ + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; +}); + +builder.Services.AddOpenTelemetry().UseOtlpExporter(); + +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => { tracing.AddSource("Marten"); }) + .WithMetrics(metrics => { metrics.AddMeter("Marten"); }); + +builder.Services.AddMarten(opts => +{ + opts.DatabaseSchemaName = "cli"; + + // Register all event store projections ahead of time + opts.Projections + .Add(new TripProjection(), ProjectionLifecycle.Async); + + opts.Projections + .Add(new DayProjection(), ProjectionLifecycle.Async); + + opts.Projections + .Add(new DistanceProjection(), ProjectionLifecycle.Async); +}).AddAsyncDaemon(DaemonMode.Solo) + + // Use PostgreSQL data source from the IoC container + .UseNpgsqlDataSource(); + +await builder.Build().RunAsync(); \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/TripBuildingService.csproj b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/TripBuildingService.csproj new file mode 100644 index 000000000..e79dc3ef1 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/TripBuildingService.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/TripProjection.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/TripProjection.cs new file mode 100644 index 000000000..fcabf4042 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripBuildingService/TripProjection.cs @@ -0,0 +1,28 @@ +using Marten.Events.Aggregation; +using TripDomain; + +public class TripProjection: SingleStreamProjection +{ + // Marten 9 replaced the predicate overloads of DeleteEvent() with the ShouldDelete + // convention. The parameterless DeleteEvent() still exists, but expressing all three + // the same way keeps the rule for each event in one place. + public bool ShouldDelete(TripAborted _) => true; + public bool ShouldDelete(Breakdown e) => e.IsCritical; + public bool ShouldDelete(VacationOver _, Trip trip) => trip.Traveled > 1000; + + // These methods can be either public, internal, or private but there's + // a small performance gain to making them public + public void Apply(Arrival e, Trip trip) => trip.State = e.State; + public void Apply(Travel e, Trip trip) => trip.Traveled += e.TotalDistance(); + + public void Apply(TripEnded e, Trip trip) + { + trip.Active = false; + trip.EndedOn = e.Day; + } + + public Trip Create(TripStarted started) + { + return new Trip { StartedOn = started.Day, Active = true }; + } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Arrival.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Arrival.cs new file mode 100644 index 000000000..e6cfdc177 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Arrival.cs @@ -0,0 +1,7 @@ +namespace TripDomain; + +public class Arrival +{ + public int Day { get; set; } + public string State { get; set; } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Departure.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Departure.cs new file mode 100644 index 000000000..97af38b63 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Departure.cs @@ -0,0 +1,7 @@ +namespace TripDomain; + +public class Departure +{ + public int Day { get; set; } + public string State { get; set; } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Direction.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Direction.cs new file mode 100644 index 000000000..5e9b03c66 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Direction.cs @@ -0,0 +1,9 @@ +namespace TripDomain; + +public enum Direction +{ + North, + South, + East, + West +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/FailingEvent.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/FailingEvent.cs new file mode 100644 index 000000000..7f526d52c --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/FailingEvent.cs @@ -0,0 +1,11 @@ +namespace TripDomain; + +public class FailingEvent +{ + public static bool SerializationFails = false; + + public FailingEvent() + { + if (SerializationFails) throw new DivideByZeroException("Boom!"); + } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/IDayEvent.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/IDayEvent.cs new file mode 100644 index 000000000..cd1bcf837 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/IDayEvent.cs @@ -0,0 +1,6 @@ +namespace TripDomain; + +public interface IDayEvent +{ + int Day { get; } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Movement.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Movement.cs new file mode 100644 index 000000000..414e425f1 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Movement.cs @@ -0,0 +1,48 @@ +namespace TripDomain; + +public class Movement +{ + public Direction Direction + { + get; + set; + + } + + public double Distance { get; set; } + + protected bool Equals(Movement other) + { + return Direction == other.Direction && Distance.Equals(other.Distance); + } + + public override string ToString() + { + return $"{nameof(Direction)}: {Direction}, {nameof(Distance)}: {Distance}"; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + + return Equals((Movement)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine((int)Direction, Distance); + } +} diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Stop.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Stop.cs new file mode 100644 index 000000000..4ceec9e00 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Stop.cs @@ -0,0 +1,8 @@ +namespace TripDomain; + +public class Stop +{ + public TimeOnly Time { get; set; } + public string State { get; set; } + public int Duration { get; set; } +} diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Travel.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Travel.cs new file mode 100644 index 000000000..67f173e07 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Travel.cs @@ -0,0 +1,48 @@ +namespace TripDomain; + +public class Travel : IDayEvent +{ + public static Travel Random(int day) + { + var travel = new Travel {Day = day,}; + + var random = System.Random.Shared; + var numberOfMovements = random.Next(1, 20); + for (var i = 0; i < numberOfMovements; i++) + { + var movement = new Movement + { + Direction = TripStream.RandomDirection(), Distance = random.Next(500, 3000) / 100 + }; + + travel.Movements.Add(movement); + } + + var numberOfStops = random.Next(1, 10); + for (var i = 0; i < numberOfStops; i++) + { + travel.Stops.Add(new Stop() + { + Time = TripStream.RandomTime(), + State = TripStream.RandomState(), + Duration = random.Next(10, 30) + }); + } + + return travel; + } + + public int Day { get; set; } + + #region sample_Travel_Movements + + public IList Movements { get; set; } = new List(); + public List Stops { get; set; } = new(); + + #endregion + + public double TotalDistance() + { + return Movements.Sum(x => x.Distance); + } +} diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Trip.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Trip.cs new file mode 100644 index 000000000..3142234c9 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/Trip.cs @@ -0,0 +1,43 @@ +namespace TripDomain; + +public class Activity +{ + public Guid Id { get; set; } +} + +public class Trip : Activity +{ + public int EndedOn { get; set; } + + public double Traveled { get; set; } + + public string State { get; set; } + + public bool Active { get; set; } + + public int StartedOn { get; set; } + public Guid? RepairShopId { get; set; } + + protected bool Equals(Trip other) + { + return Id.Equals(other.Id) && EndedOn == other.EndedOn && Traveled.Equals(other.Traveled) && State == other.State && Active == other.Active && StartedOn == other.StartedOn; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((Trip) obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(Id, EndedOn, Traveled, State, Active, StartedOn); + } + + public override string ToString() + { + return $"{nameof(Id)}: {Id}, {nameof(EndedOn)}: {EndedOn}, {nameof(Traveled)}: {Traveled}, {nameof(State)}: {State}, {nameof(Active)}: {Active}, {nameof(StartedOn)}: {StartedOn}"; + } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripAborted.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripAborted.cs new file mode 100644 index 000000000..07339bdef --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripAborted.cs @@ -0,0 +1,16 @@ +namespace TripDomain; + +public class TripAborted +{ + +} + +public class Breakdown +{ + public bool IsCritical { get; set; } +} + +public class VacationOver +{ + +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripDomain.csproj b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripDomain.csproj new file mode 100644 index 000000000..14baaa646 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripDomain.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripEnded.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripEnded.cs new file mode 100644 index 000000000..6396a5ace --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripEnded.cs @@ -0,0 +1,7 @@ +namespace TripDomain; + +public class TripEnded : IDayEvent +{ + public int Day { get; set; } + public string State { get; set; } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripStarted.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripStarted.cs new file mode 100644 index 000000000..a22ac01d9 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripStarted.cs @@ -0,0 +1,6 @@ +namespace TripDomain; + +public class TripStarted : IDayEvent +{ + public int Day { get; set; } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripStream.cs b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripStream.cs new file mode 100644 index 000000000..d2494f349 --- /dev/null +++ b/src/Samples/Bobcat/MartenWithProjectAspire/TripDomain/TripStream.cs @@ -0,0 +1,175 @@ +using JasperFx.Events; +using Marten.Events; + +namespace TripDomain; + +public class TripStream +{ + public static List RandomStreams(int number) + { + var list = new List(); + for (var i = 0; i < number; i++) + { + var stream = new TripStream(); + list.Add(stream); + } + + return list; + } + + public static readonly string[] States = new string[] {"Texas", "Arkansas", "Missouri", "Kansas", "Oklahoma", "Connecticut", "New Jersey", "New York" }; + + public static string RandomState() + { + var index = Random.Shared.Next(0, States.Length - 1); + return States[index]; + } + + public static Direction RandomDirection() + { + var index = Random.Shared.Next(0, 3); + switch (index) + { + case 0: + return Direction.East; + case 1: + return Direction.North; + case 2: + return Direction.South; + default: + return Direction.West; + } + } + + public static TimeOnly RandomTime() + { + var hour = Random.Shared.Next(0, 24); + return new TimeOnly(hour, 0, 0); + } + + public Guid StreamId = Guid.NewGuid(); + + public readonly List Events = new List(); + + + + public TripStream() + { + var random = Random.Shared; + var startDay = random.Next(1, 100); + + var start = new TripStarted {Day = startDay}; + Events.Add(start); + + + var state = RandomState(); + + Events.Add(new Departure{Day = startDay, State = state}); + + var duration = random.Next(1, 20); + + var randomNumber = random.NextDouble(); + for (var i = 0; i < duration; i++) + { + var day = startDay + i; + + var travel = Travel.Random(day); + Events.Add(travel); + + if (i > 0 && randomNumber > .3) + { + var departure = new Departure {Day = day, State = state}; + + Events.Add(departure); + + state = RandomState(); + + var arrival = new Arrival {State = state, Day = i}; + Events.Add(arrival); + } + } + + Events.Add(new FailingEvent()); + + if (randomNumber > .5) + { + Events.Add(new TripEnded + { + Day = startDay + duration, + State = state + }); + } + else if (randomNumber > .9) + { + Events.Add(new TripAborted()); + } + + + } + + public bool IsFinishedPublishing() + { + return _index >= Events.Count; + } + + private readonly Mutex _mutex = new Mutex(); + private int _index; + + + + public void Reset() + { + _index = 0; + } + + public bool TryCheckOutEvents(out object[] events) + { + if (IsFinishedPublishing()) + { + events = new object[0]; + return false; + } + + var number = Random.Shared.Next(1, 5); + + if (_index + number >= Events.Count) + { + events = Events.Skip(_index).ToArray(); + } + else + { + events = Events.GetRange(_index, number).ToArray(); + } + + _index += number; + return true; + } + + public StreamAction ToAction(EventGraph graph) + { + return StreamAction.Start(graph, StreamId, Events.ToArray()); + } + + public string TenantId { get; set; } + + public TripStream TravelIsUnder(int miles) + { + var movements = Events.OfType().SelectMany(x => x.Movements).ToArray(); + foreach (var movement in movements) + { + if (movements.Sum(x => x.Distance) > miles) + { + movement.Distance = 0.1; + } + } + + return this; + } + + public TripStream TravelIsOver(int miles) + { + var movements = Events.OfType().SelectMany(x => x.Movements).ToArray(); + movements[0].Distance = miles + 1; + return this; + } +} \ No newline at end of file diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Administration/AcceptMeetingGroupProposal.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Administration/AcceptMeetingGroupProposal.cs new file mode 100644 index 000000000..5324f09a8 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Administration/AcceptMeetingGroupProposal.cs @@ -0,0 +1,26 @@ +using Marten; +using MeetingGroupMonolith; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Administration; + +public static class AcceptMeetingGroupProposalEndpoint +{ + // Cascade: accepting a proposal publishes MeetingGroupProposalAcceptedEvent + // so the Meetings module creates the actual MeetingGroup + [WolverinePost("/api/administration/proposals/{id}/accept")] + public static (MeetingGroupProposal, MeetingGroupProposalAcceptedEvent) Post( + Guid id, + [Entity(Required = true)] MeetingGroupProposal proposal, + IDocumentSession session) + { + proposal.Status = ProposalStatus.Accepted; + proposal.DecisionDate = DateTimeOffset.UtcNow; + session.Store(proposal); + + return (proposal, new MeetingGroupProposalAcceptedEvent( + proposal.Id, proposal.Name, proposal.Description, + proposal.LocationCity, proposal.LocationCountryCode, proposal.ProposalUserId)); + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Administration/MeetingGroupProposal.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Administration/MeetingGroupProposal.cs new file mode 100644 index 000000000..4bd9e5530 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Administration/MeetingGroupProposal.cs @@ -0,0 +1,22 @@ +namespace Administration; + +public class MeetingGroupProposal +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string LocationCity { get; set; } = string.Empty; + public string LocationCountryCode { get; set; } = string.Empty; + public Guid ProposalUserId { get; set; } + public ProposalStatus Status { get; set; } = ProposalStatus.InVerification; + public string? DecisionReason { get; set; } + public DateTimeOffset ProposalDate { get; set; } + public DateTimeOffset? DecisionDate { get; set; } +} + +public enum ProposalStatus +{ + InVerification, + Accepted, + Rejected, +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Administration/ProposeNewMeetingGroup.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Administration/ProposeNewMeetingGroup.cs new file mode 100644 index 000000000..9dc12ce34 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Administration/ProposeNewMeetingGroup.cs @@ -0,0 +1,53 @@ +using FluentValidation; +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Administration; + +public record ProposeNewMeetingGroup(string Name, string Description, string LocationCity, string LocationCountryCode, Guid ProposalUserId) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.LocationCity).NotEmpty(); + RuleFor(x => x.LocationCountryCode).NotEmpty().Length(2); + } + } +} + +/// +/// 201 Created with a Location header pointing at the new proposal. Wolverine.HTTP's +/// is the idiomatic way to say 201 from an endpoint, and unlike +/// an IResult it can sit in the first slot of a cascading-message tuple. +/// +public record ProposalCreation(Guid Id) : CreationResponse($"/api/administration/proposals/{Id}"); + +public static class ProposeNewMeetingGroupEndpoint +{ + [WolverinePost("/api/administration/proposals")] + public static ProposalCreation Post(ProposeNewMeetingGroup command, IDocumentSession session) + { + var proposal = new MeetingGroupProposal + { + Id = Guid.NewGuid(), + Name = command.Name, + Description = command.Description, + LocationCity = command.LocationCity, + LocationCountryCode = command.LocationCountryCode, + ProposalUserId = command.ProposalUserId, + ProposalDate = DateTimeOffset.UtcNow, + }; + + session.Store(proposal); + return new ProposalCreation(proposal.Id); + } +} + +public static class GetMeetingGroupProposalEndpoint +{ + [WolverineGet("/api/administration/proposals/{id}")] + public static MeetingGroupProposal? Get(Guid id, [Entity] MeetingGroupProposal? proposal) => proposal; +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/IntegrationEvents.cs b/src/Samples/Bobcat/MeetingGroupMonolith/IntegrationEvents.cs new file mode 100644 index 000000000..126ec2b5c --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/IntegrationEvents.cs @@ -0,0 +1,10 @@ +namespace MeetingGroupMonolith; + +// Integration events for cross-module communication via durable local queues. +// Replaces the custom InMemoryEventBus + OutboxMessages + InboxMessages + Quartz jobs. + +public record NewUserRegisteredEvent(Guid UserId, string Login, string Email, string FirstName, string LastName); +public record MeetingGroupProposalAcceptedEvent(Guid ProposalId, string Name, string Description, string LocationCity, string LocationCountryCode, Guid ProposalUserId); +public record MeetingAttendeeAddedEvent(Guid MeetingId, Guid AttendeeId, Guid MeetingGroupId); +public record MeetingFeePaidEvent(Guid MeetingFeeId, Guid PaymentId); +public record SubscriptionExpirationChangedEvent(Guid PayerId, DateTime ExpirationDate); diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/MeetingGroupMonolith.csproj b/src/Samples/Bobcat/MeetingGroupMonolith/MeetingGroupMonolith.csproj new file mode 100644 index 000000000..37967bc0c --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/MeetingGroupMonolith.csproj @@ -0,0 +1,33 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/MeetingGroupMonolith.slnx b/src/Samples/Bobcat/MeetingGroupMonolith/MeetingGroupMonolith.slnx new file mode 100644 index 000000000..10abcba81 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/MeetingGroupMonolith.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/AddAttendee.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/AddAttendee.cs new file mode 100644 index 000000000..03ca18266 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/AddAttendee.cs @@ -0,0 +1,45 @@ +using Marten; +using MeetingGroupMonolith; +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Meetings; + +public record AddAttendee(Guid MeetingId, Guid MemberId); + +public static class AddAttendeeEndpoint +{ + public static ProblemDetails Validate(AddAttendee command, [Entity] Meeting? meeting) + { + if (meeting is null) + return new ProblemDetails { Detail = "Meeting not found", Status = 404 }; + if (meeting.Status == MeetingStatus.Cancelled) + return new ProblemDetails { Detail = "Cannot join a cancelled meeting", Status = 400 }; + if (meeting.AttendeesLimit.HasValue && meeting.Attendees.Count >= meeting.AttendeesLimit.Value) + return new ProblemDetails { Detail = "Meeting is full", Status = 400 }; + if (meeting.Attendees.Any(a => a.MemberId == command.MemberId)) + return new ProblemDetails { Detail = "Already attending", Status = 409 }; + + return WolverineContinue.NoProblems; + } + + // Cascade: adding an attendee to a paid meeting publishes MeetingAttendeeAddedEvent + // so the Payments module can create a MeetingFee + [WolverinePost("/api/meetings/{meetingId}/attendees")] + public static (Meeting, MeetingAttendeeAddedEvent) Post( + AddAttendee command, + [Entity("MeetingId", Required = true)] Meeting meeting, + IDocumentSession session) + { + meeting.Attendees.Add(new MeetingAttendee + { + MemberId = command.MemberId, + AddedAt = DateTimeOffset.UtcNow, + }); + + session.Store(meeting); + + return (meeting, new MeetingAttendeeAddedEvent(meeting.Id, command.MemberId, meeting.MeetingGroupId)); + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/CreateMeeting.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/CreateMeeting.cs new file mode 100644 index 000000000..da58b6712 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/CreateMeeting.cs @@ -0,0 +1,59 @@ +using FluentValidation; +using Marten; +using MeetingGroupMonolith; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Meetings; + +public record CreateMeeting( + Guid MeetingGroupId, + string Title, + string Description, + DateTime TermStartDate, + DateTime TermEndDate, + string LocationAddress, + int? AttendeesLimit, + decimal Fee +) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.MeetingGroupId).NotEmpty(); + RuleFor(x => x.TermStartDate).LessThan(x => x.TermEndDate); + } + } +} + +/// 201 Created with a Location header pointing at the new meeting. +public record MeetingCreation(Guid Id) : CreationResponse($"/api/meetings/{Id}"); + +public static class CreateMeetingEndpoint +{ + [WolverinePost("/api/meetings")] + public static MeetingCreation Post( + CreateMeeting command, + [Entity("MeetingGroupId", Required = true)] MeetingGroup group, + IDocumentSession session) + { + var meeting = new Meeting + { + Id = Guid.NewGuid(), + MeetingGroupId = group.Id, + Title = command.Title, + Description = command.Description, + TermStartDate = command.TermStartDate, + TermEndDate = command.TermEndDate, + LocationAddress = command.LocationAddress, + AttendeesLimit = command.AttendeesLimit, + Fee = command.Fee, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(meeting); + return new MeetingCreation(meeting.Id); + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/GetMeetingGroups.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/GetMeetingGroups.cs new file mode 100644 index 000000000..f95c28416 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/GetMeetingGroups.cs @@ -0,0 +1,52 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Meetings; + +public static class GetMeetingGroupsEndpoint +{ + [WolverineGet("/api/meeting-groups")] + public static Task> Get(IQuerySession session, CancellationToken ct) + => session.Query().ToListAsync(ct); +} + +public static class GetMeetingGroupByIdEndpoint +{ + [WolverineGet("/api/meeting-groups/{id}")] + public static MeetingGroup? Get(Guid id, [Entity] MeetingGroup? group) => group; +} + +public static class GetMeetingGroupMeetingsEndpoint +{ + [WolverineGet("/api/meeting-groups/{id}/meetings")] + public static Task> Get(Guid id, IQuerySession session, CancellationToken ct) + => session.Query() + .Where(m => m.MeetingGroupId == id) + .OrderByDescending(m => m.TermStartDate) + .ToListAsync(ct); +} + +public static class GetMeetingsEndpoint +{ + [WolverineGet("/api/meetings")] + public static Task> Get(IQuerySession session, CancellationToken ct) + => session.Query().OrderByDescending(m => m.TermStartDate).ToListAsync(ct); +} + +public static class GetMeetingByIdEndpoint +{ + [WolverineGet("/api/meetings/{id}")] + public static Meeting? Get(Guid id, [Entity] Meeting? meeting) => meeting; +} + +/// +/// The Meetings module's view of a user. A Member only exists because the Registrations module +/// published and this module handled +/// it, so this is the endpoint that makes that cascade observable from outside the process. +/// +public static class GetMemberByIdEndpoint +{ + [WolverineGet("/api/members/{id}")] + public static Member? Get(Guid id, [Entity] Member? member) => member; +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/IntegrationEventHandlers.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/IntegrationEventHandlers.cs new file mode 100644 index 000000000..10536062c --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/IntegrationEventHandlers.cs @@ -0,0 +1,66 @@ +using Marten; +using MeetingGroupMonolith; + +namespace Meetings; + +/// +/// Handles NewUserRegisteredEvent from the Registrations module. +/// Creates a Member document in the Meetings module. +/// Replaces: InboxMessage → ProcessInboxJob → MediatR Notification → handler +/// +public static class NewUserRegisteredHandler +{ + public static void Handle(NewUserRegisteredEvent message, IDocumentSession session) + { + session.Store(new Member + { + Id = message.UserId, + Login = message.Login, + Email = message.Email, + FirstName = message.FirstName, + LastName = message.LastName, + }); + } +} + +/// +/// Handles MeetingGroupProposalAcceptedEvent from the Administration module. +/// Creates the actual MeetingGroup. +/// +/// The group takes the proposal's id, as the original's MeetingGroup.CreateBasedOnProposal +/// did. It is what makes the cascade observable: a caller that accepted proposal X can then +/// GET /api/meeting-groups/X and see whether the Meetings module has caught up, instead of +/// searching the whole list for a group with a matching name. +/// +public static class MeetingGroupProposalAcceptedHandler +{ + public static void Handle(MeetingGroupProposalAcceptedEvent message, IDocumentSession session) + { + session.Store(new MeetingGroup + { + Id = message.ProposalId, + Name = message.Name, + Description = message.Description, + LocationCity = message.LocationCity, + LocationCountryCode = message.LocationCountryCode, + CreatorId = message.ProposalUserId, + Members = [new MeetingGroupMember { MemberId = message.ProposalUserId, Role = "Organizer", JoinedAt = DateTimeOffset.UtcNow }], + CreatedAt = DateTimeOffset.UtcNow, + }); + } +} + +/// +/// Handles SubscriptionExpirationChangedEvent from the Payments module. +/// +public static class SubscriptionExpirationChangedHandler +{ + public static async Task Handle(SubscriptionExpirationChangedEvent message, IDocumentSession session) + { + var member = await session.LoadAsync(message.PayerId); + if (member is null) return; + + member.SubscriptionExpirationDate = message.ExpirationDate; + session.Store(member); + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/MeetingGroup.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/MeetingGroup.cs new file mode 100644 index 000000000..7a994ca22 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Meetings/MeetingGroup.cs @@ -0,0 +1,55 @@ +namespace Meetings; + +public class MeetingGroup +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string LocationCity { get; set; } = string.Empty; + public string LocationCountryCode { get; set; } = string.Empty; + public Guid CreatorId { get; set; } + public List Members { get; set; } = []; + public DateTimeOffset CreatedAt { get; set; } +} + +public class MeetingGroupMember +{ + public Guid MemberId { get; set; } + public string Role { get; set; } = "Member"; // Organizer, Member + public DateTimeOffset JoinedAt { get; set; } +} + +public class Meeting +{ + public Guid Id { get; set; } + public Guid MeetingGroupId { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime TermStartDate { get; set; } + public DateTime TermEndDate { get; set; } + public string LocationAddress { get; set; } = string.Empty; + public int? AttendeesLimit { get; set; } + public decimal Fee { get; set; } + public List Attendees { get; set; } = []; + public MeetingStatus Status { get; set; } = MeetingStatus.Active; + public DateTimeOffset CreatedAt { get; set; } +} + +public class MeetingAttendee +{ + public Guid MemberId { get; set; } + public DateTimeOffset AddedAt { get; set; } + public bool FeePaid { get; set; } +} + +public enum MeetingStatus { Active, Cancelled } + +public class Member +{ + public Guid Id { get; set; } + public string Login { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public DateTime? SubscriptionExpirationDate { get; set; } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Payments/CreateSubscription.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Payments/CreateSubscription.cs new file mode 100644 index 000000000..beecd009e --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Payments/CreateSubscription.cs @@ -0,0 +1,60 @@ +using FluentValidation; +using Marten; +using MeetingGroupMonolith; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Payments; + +public record CreateSubscription(Guid PayerId, string Period) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.PayerId).NotEmpty(); + RuleFor(x => x.Period).NotEmpty().Must(p => p is "Monthly" or "HalfYearly" or "Yearly"); + } + } +} + +/// 201 Created with a Location header pointing at the new subscription. +public record SubscriptionCreation(Guid Id) : CreationResponse($"/api/payments/subscriptions/{Id}"); + +public static class CreateSubscriptionEndpoint +{ + // Event-sourced: starts a new event stream in Marten's event store. + // Cascading message notifies the Meetings module of the subscription change. + [WolverinePost("/api/payments/subscriptions")] + public static (SubscriptionCreation, SubscriptionExpirationChangedEvent) Post( + CreateSubscription command, + IDocumentSession session) + { + var subscriptionId = Guid.NewGuid(); + var expirationDate = command.Period switch + { + "Monthly" => DateTime.UtcNow.AddMonths(1), + "HalfYearly" => DateTime.UtcNow.AddMonths(6), + "Yearly" => DateTime.UtcNow.AddYears(1), + _ => DateTime.UtcNow.AddMonths(1), + }; + + // Start a new event stream — Marten stores the event and + // the Subscription snapshot is built via the Apply methods + session.Events.StartStream( + subscriptionId, + new SubscriptionCreated(subscriptionId, command.PayerId, command.Period, expirationDate)); + + return (new SubscriptionCreation(subscriptionId), new SubscriptionExpirationChangedEvent(command.PayerId, expirationDate)); + } +} + +/// +/// Reads the inline snapshot of the subscription aggregate — the document Marten rebuilt from +/// the stream's events, which is the claim an event-sourced module exists to make. +/// +public static class GetSubscriptionEndpoint +{ + [WolverineGet("/api/payments/subscriptions/{id}")] + public static Subscription? Get(Guid id, [Entity] Subscription? subscription) => subscription; +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Payments/MeetingAttendeeAddedHandler.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Payments/MeetingAttendeeAddedHandler.cs new file mode 100644 index 000000000..fc1fe8d01 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Payments/MeetingAttendeeAddedHandler.cs @@ -0,0 +1,20 @@ +using Marten; +using MeetingGroupMonolith; + +namespace Payments; + +/// +/// Handles MeetingAttendeeAddedEvent from the Meetings module. +/// Creates a MeetingFee in the Payments event store. +/// Replaces: InboxMessage → ProcessInboxJob → MediatR → handler + SqlStreamStore +/// +public static class MeetingAttendeeAddedHandler +{ + public static void Handle(MeetingAttendeeAddedEvent message, IDocumentSession session) + { + var feeId = Guid.NewGuid(); + session.Events.StartStream( + feeId, + new MeetingFeeCreated(feeId, message.AttendeeId, message.MeetingId, 0m)); + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Payments/Subscription.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Payments/Subscription.cs new file mode 100644 index 000000000..100d59a0b --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Payments/Subscription.cs @@ -0,0 +1,48 @@ +using Marten.Events.Aggregation; + +namespace Payments; + +// --- Domain Events (stored in Marten event store) --- + +public record SubscriptionCreated(Guid SubscriptionId, Guid PayerId, string Period, DateTime ExpirationDate); +public record SubscriptionRenewed(Guid SubscriptionId, DateTime NewExpirationDate); +public record SubscriptionExpired(Guid SubscriptionId); +public record MeetingFeeCreated(Guid MeetingFeeId, Guid PayerId, Guid MeetingId, decimal Amount); +public record MeetingFeePaid(Guid MeetingFeeId, Guid PaymentId); + +/// +/// Subscription aggregate — event-sourced via Marten. +/// Replaces SqlStreamStore + custom Load(IEnumerable<IDomainEvent>) pattern. +/// Marten rebuilds state by calling Apply methods automatically. +/// +public class Subscription +{ + public Guid Id { get; set; } + public Guid PayerId { get; set; } + public string Period { get; set; } = string.Empty; + public DateTime ExpirationDate { get; set; } + public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Active; + + // Marten calls these automatically when replaying events + public void Apply(SubscriptionCreated e) + { + Id = e.SubscriptionId; + PayerId = e.PayerId; + Period = e.Period; + ExpirationDate = e.ExpirationDate; + Status = SubscriptionStatus.Active; + } + + public void Apply(SubscriptionRenewed e) + { + ExpirationDate = e.NewExpirationDate; + Status = SubscriptionStatus.Active; + } + + public void Apply(SubscriptionExpired _) + { + Status = SubscriptionStatus.Expired; + } +} + +public enum SubscriptionStatus { Active, Expired } diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Program.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Program.cs new file mode 100644 index 000000000..8190a3837 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Program.cs @@ -0,0 +1,93 @@ +using JasperFx.Events.Projections; +using Marten; +using Marten.Events.Projections; +using MeetingGroupMonolith; +using Payments; +using Wolverine; +using Wolverine.FluentValidation; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=meeting_groups;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + + // Schema-per-module — each module's documents are isolated + opts.Schema.For().DatabaseSchemaName("users"); + opts.Schema.For().DatabaseSchemaName("administration"); + opts.Schema.For().DatabaseSchemaName("meetings"); + opts.Schema.For().DatabaseSchemaName("meetings"); + opts.Schema.For().DatabaseSchemaName("meetings"); + + // Event sourcing for the Payments module — replaces SqlStreamStore + opts.Events.DatabaseSchemaName = "payments"; + opts.Projections.Snapshot(SnapshotLifecycle.Inline); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); + opts.ServiceName = "MeetingGroups"; + + // Durable local queues for inter-module messaging + // Messages survive restarts via the Marten outbox + opts.LocalQueue("registrations").UseDurableInbox(); + opts.LocalQueue("administration").UseDurableInbox(); + opts.LocalQueue("meetings").UseDurableInbox(); + opts.LocalQueue("payments").UseDurableInbox(); + + // Route integration events to module-specific durable queues + opts.Publish(x => + { + x.Message(); + x.ToLocalQueue("meetings"); + }); + + opts.Publish(x => + { + x.Message(); + x.ToLocalQueue("meetings"); + }); + + opts.Publish(x => + { + x.Message(); + x.ToLocalQueue("payments"); + }); + + opts.Publish(x => + { + x.Message(); + x.ToLocalQueue("meetings"); + }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +await app.RunAsync(); diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/README.md b/src/Samples/Bobcat/MeetingGroupMonolith/README.md new file mode 100644 index 000000000..65f32924b --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/README.md @@ -0,0 +1,90 @@ +# Meeting Group Modular Monolith — Critter Stack Conversion + +## Original Project + +**Repository:** [kgrzybek/modular-monolith-with-ddd](https://github.com/kgrzybek/modular-monolith-with-ddd) +**License:** MIT +**Stack:** SQL Server, custom MediatR infrastructure, Autofac, Quartz.NET, SqlStreamStore (event sourcing), custom outbox/inbox + +One of the most-starred modular monolith references in .NET (~12k stars). A Meetup.com-like meeting group scheduling domain with 5 modules, DDD tactical patterns, event sourcing in the Payments module, and custom outbox/inbox for inter-module messaging. + +## What Changed + +### Removed (massive custom infrastructure) +- **Custom outbox** — `OutboxMessages` table + `ProcessOutboxCommand` Quartz job per module +- **Custom inbox** — `InboxMessages` table + `ProcessInboxCommand` Quartz job per module +- **Internal commands** — `InternalCommands` table + `ProcessInternalCommandsCommand` per module +- **InMemoryEventBus** — custom pub/sub dictionary +- **SqlStreamStore** — custom event store over SQL Server (Streams, Messages, Checkpoints tables) +- **Quartz.NET** — 3 recurring jobs per module (15 total) +- **Autofac** — custom MediatorModule, decorator chains, contravariant registrations +- **MediatR pipeline decorators** — UnitOfWork, Validation, Logging per module +- **SQL Server** — replaced by PostgreSQL (Marten) +- **DbUp migrations** — Marten auto-creates schemas + +### Before vs After + +| Aspect | Original | Converted | +|--------|----------|-----------| +| Projects | 20+ (4 per module × 5 modules + BuildingBlocks) | 1 | +| Custom infrastructure files | ~50 (outbox, inbox, internal commands, event bus) | 0 | +| Event store | SqlStreamStore (Streams + Messages tables) | Marten Event Store | +| Outbox/Inbox | Custom tables + Quartz polling jobs | Wolverine durable local queues | +| Scheduled jobs | Quartz.NET (15 jobs) | Wolverine message scheduling | +| Database | SQL Server | PostgreSQL (Marten) | +| DI container | Autofac with custom modules | Built-in .NET DI | + +### Architecture + +``` +MeetingGroupMonolith/ + Program.cs ← single host, schema-per-module, durable local queues + IntegrationEvents.cs ← shared messages between modules + UserAccess/ ← User identity + Registrations/ ← User registration flow + Administration/ ← Meeting group proposals & approval + Meetings/ ← Meeting groups, meetings, attendance + Payments/ ← Subscriptions & fees (EVENT SOURCED via Marten) +``` + +### Event Sourcing (Payments module) + +The Payments module uses Marten's event store instead of SqlStreamStore: + +```csharp +// Start a subscription stream with a domain event +session.Events.StartStream(subscriptionId, + new SubscriptionCreated(subscriptionId, payerId, "Monthly", expirationDate)); + +// Marten rebuilds state via Apply methods on the Subscription aggregate +public class Subscription +{ + public void Apply(SubscriptionCreated e) { /* set state */ } + public void Apply(SubscriptionRenewed e) { /* update expiration */ } + public void Apply(SubscriptionExpired _) { /* mark expired */ } +} +``` + +### Inter-Module Communication + +Each integration event is routed to a module-specific durable local queue: + +```csharp +opts.LocalQueue("meetings").UseDurableInbox(); +opts.Publish(x => { + x.Message(); + x.ToLocalQueue("meetings"); +}); +``` + +Messages survive process restarts via the Marten outbox — replacing the 15 Quartz polling jobs. + +## Running + +Requires PostgreSQL only. + +```bash +dotnet run +``` + +Swagger UI at `/swagger`. diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Registrations/RegisterUser.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Registrations/RegisterUser.cs new file mode 100644 index 000000000..07cc46665 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Registrations/RegisterUser.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using Marten; +using MeetingGroupMonolith; +using UserAccess; +using Wolverine.Http; + +namespace Registrations; + +public record RegisterUser(string Login, string Email, string FirstName, string LastName, string Password) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Login).NotEmpty().MaximumLength(100); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8); + } + } +} + +public static class RegisterUserEndpoint +{ + // Cascading message: create the User and publish NewUserRegisteredEvent + // to the durable local queue so the Meetings module can create a Member + [WolverinePost("/api/registrations")] + public static (User, NewUserRegisteredEvent) Post(RegisterUser command, IDocumentSession session) + { + var user = new User + { + Id = Guid.NewGuid(), + Login = command.Login, + Email = command.Email, + FirstName = command.FirstName, + LastName = command.LastName, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(user); + + return (user, new NewUserRegisteredEvent(user.Id, user.Login, user.Email, user.FirstName, user.LastName)); + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..75afec734 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/AssemblyAttributes.cs @@ -0,0 +1,24 @@ +using MeetingGroupMonolith.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(MeetingGroupMonolithHost))] + +// Every spec empties the store before it runs (see MeetingGroupMonolithSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tells ASP.NET Core's WebApplicationFactory (which Alba uses) where the host's content root +// is. Without it, discovery walks up from the test assembly's bin directory and synthesizes +// `samples/MeetingGroupMonolith/MeetingGroupMonolith/` from the host assembly name — a folder +// that does not exist — and throws DirectoryNotFoundException. The Tests project lives INSIDE +// the host project rather than beside it, which is not the layout the default assumes. +// +// The path is relative to the test assembly's bin directory at run time: from +// `samples/MeetingGroupMonolith/Tests/bin//net10.0/`, four levels up is +// `samples/MeetingGroupMonolith/`, where the host's appsettings.json lives. +[assembly: WebApplicationFactoryContentRoot( + "MeetingGroupMonolith", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupMonolithHost.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupMonolithHost.cs new file mode 100644 index 000000000..8e2206605 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupMonolithHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace MeetingGroupMonolith.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class MeetingGroupMonolithHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupMonolithSpec.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupMonolithSpec.cs new file mode 100644 index 000000000..2511b2734 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupMonolithSpec.cs @@ -0,0 +1,82 @@ +using Alba; +using Wolverine; +using Wolverine.Tracking; + +namespace MeetingGroupMonolith.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every MeetingGroupMonolith spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// +/// It is Bobcat's store-only as well, so a spec can assert what an act +/// appended to the event store and what the aggregate became. A When step goes through +/// , which takes the store's high-water mark, runs the request, +/// and reads back everything appended since -- so ThenEvents and ThenNoEvents judge exactly +/// what that request appended, cascades included. +/// +/// +/// The HTTP status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a +/// spec asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +/// +public abstract class MeetingGroupMonolithSpec(MeetingGroupMonolithHost app) : CritterStackSpec(app.Host), IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // Not load-bearing for correctness -- every spec registers its own user and proposes its own group. The "is listed" assertions read whole collections, though, and without the reset those grow on every run. Both halves are needed: the Payments module is event-sourced. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } + + /// + /// Run an HTTP call and wait for every message it cascades to be fully handled. The modules in this + /// sample talk over UseDurableInbox() local queues, so the response goes out before the next + /// module has handled the cascade; asserting straight off it would race the handler. + /// + protected async Task> TrackedAsync(Func>> call) + { + HttpResult? captured = null; + + // Explicitly typed: ExecuteAndWaitAsync overloads on Task and ValueTask, and an async lambda is + // convertible to both. + Func act = async _ => { captured = await call(); }; + + await Alba.TrackActivity() + .Timeout(TimeSpan.FromSeconds(30)) + .ExecuteAndWaitAsync(act); + + return captured!; + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupSpecs.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupSpecs.cs new file mode 100644 index 000000000..e964f5734 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/MeetingGroupSpecs.cs @@ -0,0 +1,367 @@ +using Administration; +using Meetings; +using Payments; +using Registrations; +using UserAccess; + +namespace MeetingGroupMonolith.Tests; + +/// +/// The modular-monolith-with-ddd conversion: five modules in one process that talk to each other only +/// through durable local queues. Registering a user makes the Meetings module create a member; accepting +/// a proposal makes it create the group; starting a subscription in Payments pushes a new expiration date +/// back onto the member in Meetings. None of those is a direct call, so these specs are as much about the +/// cascade arriving as about the endpoint returning -- every cascading write is tracked until it is +/// handled. +/// +/// +/// The Payments module is event-sourced, so the subscription and attendee specs also assert the events +/// their request appended -- the attendee's fee stream is started by a cascade, and WhenActing reads it +/// back because the tracked request does not return until that cascade is handled. +/// +[BobcatFeature("Meeting Group Monolith")] +public class MeetingGroupSpecs(MeetingGroupMonolithHost app) : MeetingGroupMonolithSpec(app) +{ + private Guid _userId; + private Guid _proposalId; + private Guid _meetingId; + private Guid _subscriptionId; + private int _lastStatusCode; + private List _meetingGroups = []; + private List _meetings = []; + + [BobcatSpec] + public async Task register_a_user() + { + await WhenAUserRegisters("user@example.com", "Password1!"); + + ThenTheResponseStatusIs(200); + await ThenAMemberExistsInTheMeetingsModule(); + } + + [BobcatSpec] + public async Task register_with_invalid_data_returns_400() + { + await WhenAUserRegisters("", "Password1!"); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task propose_a_meeting_group() + { + await GivenARegisteredUser("proposer@example.com", "Password1!"); + + await WhenAMeetingGroupIsProposed("DDD Group", "Warsaw", "PL"); + + ThenTheResponseStatusIs(201); + await ThenTheProposalIs(ProposalStatus.InVerification); + } + + [BobcatSpec] + public async Task propose_a_meeting_group_without_a_city_returns_400() + { + await GivenARegisteredUser("lost@example.com", "Password1!"); + + await WhenAMeetingGroupIsProposed("Nowhere Group", "", "PL"); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task accept_a_meeting_group_proposal() + { + await GivenARegisteredUser("accepter@example.com", "Password1!"); + await GivenAProposedMeetingGroup("Clean Code Group", "Krakow", "PL"); + + await WhenTheProposalIsAccepted(); + + ThenTheResponseStatusIs(200); + await ThenTheProposalIs(ProposalStatus.Accepted); + await ThenAMeetingGroupExistsForTheProposal("Clean Code Group"); + await ThenTheProposerIsAnOrganizerOfTheGroup(); + } + + [BobcatSpec] + public async Task create_a_meeting() + { + await GivenARegisteredUser("organizer@example.com", "Password1!"); + await GivenAProposedMeetingGroup("Agile Group", "Gdansk", "PL"); + await GivenTheProposalWasAccepted(); + + await WhenAMeetingIsCreated("First Meetup"); + + ThenTheResponseStatusIs(201); + await ThenTheMeetingBelongsToTheGroup("First Meetup"); + } + + [BobcatSpec] + public async Task add_an_attendee() + { + await GivenARegisteredUser("attendee@example.com", "Password1!"); + await GivenAProposedMeetingGroup("DevOps Group", "Poznan", "PL"); + await GivenTheProposalWasAccepted(); + await GivenAMeeting("DevOps Meetup"); + + await WhenTheUserIsAddedAsAnAttendee(); + + ThenTheResponseStatusIs(200); + await ThenTheMeetingHasAttendees(1); + ThenEvents(Specify().With(x => x.PayerId, _userId).With(x => x.MeetingId, _meetingId)); + } + + [BobcatSpec] + public async Task adding_the_same_attendee_twice_returns_409() + { + await GivenARegisteredUser("eager@example.com", "Password1!"); + await GivenAProposedMeetingGroup("Eager Group", "Katowice", "PL"); + await GivenTheProposalWasAccepted(); + await GivenAMeeting("Eager Meetup"); + await GivenTheUserWasAddedAsAnAttendee(); + + await WhenTheUserIsAddedAsAnAttendee(); + + ThenTheResponseStatusIs(409); + await ThenTheMeetingHasAttendees(1); + ThenNoEvents(); + } + + [BobcatSpec] + public async Task get_meeting_groups() + { + await GivenARegisteredUser("viewer@example.com", "Password1!"); + await GivenAProposedMeetingGroup("Viewer Group", "Lodz", "PL"); + await GivenTheProposalWasAccepted(); + + await WhenAllMeetingGroupsAreRead(); + + ThenTheMeetingGroupIsListed("Viewer Group"); + } + + [BobcatSpec] + public async Task get_meetings_for_a_group() + { + await GivenARegisteredUser("meeting.viewer@example.com", "Password1!"); + await GivenAProposedMeetingGroup("Meeting Viewer Group", "Wroclaw", "PL"); + await GivenTheProposalWasAccepted(); + await GivenAMeeting("Visible Meeting"); + + await WhenAllMeetingsForTheGroupAreRead(); + + ThenTheMeetingIsListed("Visible Meeting"); + } + + [BobcatSpec] + public async Task create_subscription() + { + await GivenARegisteredUser("subscriber@example.com", "Password1!"); + + await WhenASubscriptionIsCreated("Monthly"); + + ThenTheResponseStatusIs(201); + ThenEvents(Specify().With(x => x.PayerId, _userId).With(x => x.Period, "Monthly")); + await ThenAggregate(TheStartedStream(), + Specify().With(x => x.Status, SubscriptionStatus.Active)); + await ThenTheSubscriptionIsActive(); + await ThenTheMembersSubscriptionExpirationIsInTheFuture(); + } + + [BobcatSpec] + public async Task create_subscription_with_an_unknown_period_returns_400() + { + await GivenARegisteredUser("badsub@example.com", "Password1!"); + + await WhenASubscriptionIsCreated("Weekly"); + + ThenTheResponseStatusIs(400); + ThenNoEvents(); + } + + // ---- registration ----------------------------------------------------------------------- + // + // A When step goes through WhenActing, so what the request appended (cascades included) is what + // ThenEvents and ThenNoEvents judge. A Given step is a plain call: it arranges, it is not the act. + + [Given("a user registered with email \"{email}\" and password \"{password}\"")] + public Task GivenARegisteredUser(string email, string password) => register(email, password); + + public Task WhenAUserRegisters(string email, string password) + => WhenActing($"a user registers with email \"{email}\" and password \"{password}\"", _ => register(email, password)); + + private async Task register(string email, string password) + { + // The login is the part of the email before the @, and the names are filler the validator insists + // on but no spec cares about. + var login = email.Split('@')[0]; + + var result = await TrackedAsync(() => PostJsonAsync( + "/api/registrations", new RegisterUser(login, email, "Test", "User", password))); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _userId = result.Body.Id; + } + + // ---- proposals -------------------------------------------------------------------------- + + [Given("a meeting group named \"{name}\" proposed in \"{city}\", \"{countryCode}\"")] + public Task GivenAProposedMeetingGroup(string name, string city, string countryCode) => propose(name, city, countryCode); + + public Task WhenAMeetingGroupIsProposed(string name, string city, string countryCode) + => WhenActing($"a meeting group named \"{name}\" is proposed in \"{city}\", \"{countryCode}\"", + _ => propose(name, city, countryCode)); + + private async Task propose(string name, string city, string countryCode) + { + var result = await PostJsonAsync( + "/api/administration/proposals", + new ProposeNewMeetingGroup(name, $"A group about {name}", city, countryCode, _userId)); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _proposalId = result.Body.Id; + } + + [Given("the meeting group proposal was accepted")] + public Task GivenTheProposalWasAccepted() => accept(); + + public Task WhenTheProposalIsAccepted() => WhenActing("the meeting group proposal is accepted", _ => accept()); + + private async Task accept() + { + // Accepting cascades MeetingGroupProposalAcceptedEvent to the Meetings module, which creates the + // group under the proposal's id. + var result = await TrackedAsync(() => PostJsonAsync( + $"/api/administration/proposals/{_proposalId}/accept", new { })); + + _lastStatusCode = result.StatusCode; + } + + // ---- meetings --------------------------------------------------------------------------- + + [Given("a meeting named \"{title}\" in the group")] + public Task GivenAMeeting(string title) => createMeeting(title); + + public Task WhenAMeetingIsCreated(string title) + => WhenActing($"a meeting named \"{title}\" is created in the group", _ => createMeeting(title)); + + private async Task createMeeting(string title) + { + var start = DateTime.UtcNow.AddDays(7); + var result = await PostJsonAsync( + "/api/meetings", + new CreateMeeting(_proposalId, title, $"About {title}", start, start.AddHours(2), + "Main Street 1", AttendeesLimit: null, Fee: 0m)); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _meetingId = result.Body.Id; + } + + [Given("the user was added as an attendee")] + public Task GivenTheUserWasAddedAsAnAttendee() => addAttendee(); + + public Task WhenTheUserIsAddedAsAnAttendee() => WhenActing("the user is added as an attendee", _ => addAttendee()); + + private async Task addAttendee() + { + // Cascades MeetingAttendeeAddedEvent to the Payments module, which starts a fee stream. + var result = await TrackedAsync(() => PostJsonAsync( + $"/api/meetings/{_meetingId}/attendees", new AddAttendee(_meetingId, _userId))); + + _lastStatusCode = result.StatusCode; + } + + // ---- queries ---------------------------------------------------------------------------- + + public Task WhenAllMeetingGroupsAreRead() => WhenActing("all meeting groups are read", async _ => + { + var result = await GetJsonAsync>("/api/meeting-groups"); + _lastStatusCode = result.StatusCode; + _meetingGroups = result.Body ?? []; + }); + + public Task WhenAllMeetingsForTheGroupAreRead() => WhenActing("all meetings for the group are read", async _ => + { + var result = await GetJsonAsync>($"/api/meeting-groups/{_proposalId}/meetings"); + _lastStatusCode = result.StatusCode; + _meetings = result.Body ?? []; + }); + + // ---- subscriptions ---------------------------------------------------------------------- + + public Task WhenASubscriptionIsCreated(string period) => WhenActing($"a {period} subscription is created", async _ => + { + // Cascades SubscriptionExpirationChangedEvent back to the Meetings module, which stamps the new + // expiration date on the Member. + var result = await TrackedAsync(() => PostJsonAsync( + "/api/payments/subscriptions", new CreateSubscription(_userId, period))); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _subscriptionId = result.Body.Id; + }); + + // ---- assertions ------------------------------------------------------------------------- + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + [Then("a member exists in the Meetings module for that user")] + public async Task ThenAMemberExistsInTheMeetingsModule() + { + var result = await GetJsonAsync($"/api/members/{_userId}"); + SpecAssert.Check("status", result.StatusCode, 200); + SpecAssert.Check("id", result.Body?.Id, _userId); + } + + [Then("the proposal is {status}")] + public async Task ThenTheProposalIs(ProposalStatus status) + => SpecAssert.Check("status", + (await GetJsonAsync($"/api/administration/proposals/{_proposalId}")).Body?.Status, status); + + [Then("a meeting group named \"{name}\" exists for the proposal")] + public async Task ThenAMeetingGroupExistsForTheProposal(string name) + => SpecAssert.Check("name", (await GetJsonAsync($"/api/meeting-groups/{_proposalId}")).Body?.Name, name); + + [Then("the proposer is an organizer of the group")] + public async Task ThenTheProposerIsAnOrganizerOfTheGroup() + { + var group = (await GetJsonAsync($"/api/meeting-groups/{_proposalId}")).Body; + SpecAssert.Fact(group?.Members.Any(m => m.MemberId == _userId && m.Role == "Organizer") == true, + $"User {_userId} is not an organizer of the group."); + } + + [Then("the meeting \"{title}\" belongs to the group")] + public async Task ThenTheMeetingBelongsToTheGroup(string title) + { + var meeting = (await GetJsonAsync($"/api/meetings/{_meetingId}")).Body; + SpecAssert.Check("title", meeting?.Title, title); + SpecAssert.Check("group", meeting?.MeetingGroupId, _proposalId); + } + + [Then("the meeting has {count} attendee")] + public async Task ThenTheMeetingHasAttendees(int count) + => SpecAssert.Check("count", (await GetJsonAsync($"/api/meetings/{_meetingId}")).Body?.Attendees.Count, count); + + [Then("the meeting group \"{name}\" is listed")] + public void ThenTheMeetingGroupIsListed(string name) + => SpecAssert.Fact(_meetingGroups.Any(g => g.Name == name), + $"The groups are [{string.Join(", ", _meetingGroups.Select(g => g.Name))}], expected '{name}'."); + + [Then("the meeting \"{title}\" is listed")] + public void ThenTheMeetingIsListed(string title) + => SpecAssert.Fact(_meetings.Any(m => m.Title == title), + $"The meetings are [{string.Join(", ", _meetings.Select(m => m.Title))}], expected '{title}'."); + + /// Reads the subscription back over HTTP: the GET serves the inline snapshot of the aggregate. + [Then("the subscription is active")] + public async Task ThenTheSubscriptionIsActive() + => SpecAssert.Check("status", + (await GetJsonAsync($"/api/payments/subscriptions/{_subscriptionId}")).Body?.Status, + SubscriptionStatus.Active); + + [Then("the member's subscription expiration is in the future")] + public async Task ThenTheMembersSubscriptionExpirationIsInTheFuture() + { + var member = (await GetJsonAsync($"/api/members/{_userId}")).Body; + SpecAssert.Fact(member?.SubscriptionExpirationDate > DateTime.UtcNow, + $"The member's subscription expires {member?.SubscriptionExpirationDate}, which is not in the future."); + } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Tests/Tests.csproj b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/Tests.csproj new file mode 100644 index 000000000..357705a68 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + MeetingGroupMonolith.Tests + MeetingGroupMonolith.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/Tests/Usings.cs b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/Usings.cs new file mode 100644 index 000000000..6602f579b --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/Tests/Usings.cs @@ -0,0 +1,4 @@ +global using Bobcat; +global using Bobcat.CritterStack; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/UserAccess/User.cs b/src/Samples/Bobcat/MeetingGroupMonolith/UserAccess/User.cs new file mode 100644 index 000000000..7e95b52e1 --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/UserAccess/User.cs @@ -0,0 +1,12 @@ +namespace UserAccess; + +public class User +{ + public Guid Id { get; set; } + public string Login { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/Samples/Bobcat/MeetingGroupMonolith/appsettings.json b/src/Samples/Bobcat/MeetingGroupMonolith/appsettings.json new file mode 100644 index 000000000..4af6e6dba --- /dev/null +++ b/src/Samples/Bobcat/MeetingGroupMonolith/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=meeting_groups;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Expertise/ExpertiseCategory.cs b/src/Samples/Bobcat/MoreSpeakers/Expertise/ExpertiseCategory.cs new file mode 100644 index 000000000..6c6e55a8b --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Expertise/ExpertiseCategory.cs @@ -0,0 +1,11 @@ +namespace Expertise; + +public class ExpertiseCategory +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public string Sector { get; set; } = string.Empty; + public List Skills { get; set; } = []; + public bool IsActive { get; set; } = true; +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Expertise/ExpertiseEndpoints.cs b/src/Samples/Bobcat/MoreSpeakers/Expertise/ExpertiseEndpoints.cs new file mode 100644 index 000000000..3026a00db --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Expertise/ExpertiseEndpoints.cs @@ -0,0 +1,38 @@ +using System.Linq.Expressions; +using Marten; +using Marten.AspNetCore; +using Marten.Linq; +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; + +namespace Expertise; + +// Compiled query — Marten pre-compiles the SQL and query plan once, +// then reuses it for every execution. Combined with WriteArray(), +// the result streams raw JSON from PostgreSQL with zero C# allocation. +public class ActiveExpertiseCategoriesQuery : ICompiledListQuery +{ + public Expression, IEnumerable>> QueryIs() + => q => q.Where(c => c.IsActive) + .OrderBy(c => c.Sector) + .ThenBy(c => c.Name); +} + +public static class GetExpertiseCategoriesEndpoint +{ + [WolverineGet("/api/expertise")] + [ProducesResponseType(200, "application/json")] + public static Task Get(IQuerySession session, HttpContext context) + => session.WriteArray(new ActiveExpertiseCategoriesQuery(), context); +} + +public static class SearchExpertiseEndpoint +{ + [WolverineGet("/api/expertise/search")] + public static Task Get(string q, IQuerySession session, HttpContext context) + => session.Query() + .Where(c => c.IsActive && ( + c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || + c.Skills.Any(s => s.Contains(q, StringComparison.OrdinalIgnoreCase)))) + .WriteArray(context); +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Mentorships/GetMentorships.cs b/src/Samples/Bobcat/MoreSpeakers/Mentorships/GetMentorships.cs new file mode 100644 index 000000000..f77e491a2 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Mentorships/GetMentorships.cs @@ -0,0 +1,41 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Mentorships; + +public static class GetMentorshipByIdEndpoint +{ + [WolverineGet("/api/mentorships/{id}")] + public static Mentorship? Get(Guid id, [Entity] Mentorship? mentorship) => mentorship; +} + +public static class GetMentorshipsForMentorEndpoint +{ + [WolverineGet("/api/mentorships/mentor/{mentorId}")] + public static Task> Get(Guid mentorId, IQuerySession session, CancellationToken ct) + => session.Query() + .Where(m => m.MentorId == mentorId) + .OrderByDescending(m => m.RequestedAt) + .ToListAsync(ct); +} + +public static class GetMentorshipsForMenteeEndpoint +{ + [WolverineGet("/api/mentorships/mentee/{menteeId}")] + public static Task> Get(Guid menteeId, IQuerySession session, CancellationToken ct) + => session.Query() + .Where(m => m.MenteeId == menteeId) + .OrderByDescending(m => m.RequestedAt) + .ToListAsync(ct); +} + +public static class GetPendingRequestsEndpoint +{ + [WolverineGet("/api/mentorships/pending/{mentorId}")] + public static Task> Get(Guid mentorId, IQuerySession session, CancellationToken ct) + => session.Query() + .Where(m => m.MentorId == mentorId && m.Status == MentorshipStatus.Pending) + .OrderBy(m => m.RequestedAt) + .ToListAsync(ct); +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Mentorships/Mentorship.cs b/src/Samples/Bobcat/MoreSpeakers/Mentorships/Mentorship.cs new file mode 100644 index 000000000..b863a16a6 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Mentorships/Mentorship.cs @@ -0,0 +1,24 @@ +namespace Mentorships; + +public class Mentorship +{ + public Guid Id { get; set; } + public Guid MentorId { get; set; } + public string MentorName { get; set; } = string.Empty; + public Guid MenteeId { get; set; } + public string MenteeName { get; set; } = string.Empty; + public MentorshipStatus Status { get; set; } = MentorshipStatus.Pending; + public MentorshipType Type { get; set; } + public List FocusAreas { get; set; } = []; + public string? RequestMessage { get; set; } + public string? ResponseMessage { get; set; } + public string? Notes { get; set; } + public string? PreferredFrequency { get; set; } + public DateTimeOffset RequestedAt { get; set; } + public DateTimeOffset? RespondedAt { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } +} + +public enum MentorshipStatus { Pending, Accepted, Active, Completed, Cancelled, Declined } +public enum MentorshipType { NewToExperienced, ExperiencedToExperienced } diff --git a/src/Samples/Bobcat/MoreSpeakers/Mentorships/RequestMentorship.cs b/src/Samples/Bobcat/MoreSpeakers/Mentorships/RequestMentorship.cs new file mode 100644 index 000000000..2a9c721b3 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Mentorships/RequestMentorship.cs @@ -0,0 +1,73 @@ +using FluentValidation; +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Speakers; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Mentorships; + +public record RequestMentorship( + Guid MentorId, + Guid MenteeId, + MentorshipType Type, + string? RequestMessage, + List? FocusAreas, + string? PreferredFrequency +) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.MentorId).NotEmpty(); + RuleFor(x => x.MenteeId).NotEmpty(); + RuleFor(x => x.MentorId).NotEqual(x => x.MenteeId) + .WithMessage("Cannot mentor yourself"); + } + } +} + +public static class RequestMentorshipEndpoint +{ + // Multiple [Entity] parameters — Wolverine batch-loads both in one round-trip + public static ProblemDetails Validate( + RequestMentorship command, + [Entity("MentorId", OnMissing = OnMissing.ProblemDetailsWith400)] Speaker? mentor, + [Entity("MenteeId", OnMissing = OnMissing.ProblemDetailsWith400)] Speaker? mentee) + { + if (mentor is not null && !mentor.IsAvailableForMentoring) + return new ProblemDetails { Detail = "Mentor is not available for mentoring", Status = 400 }; + + return WolverineContinue.NoProblems; + } + + // Created rather than a bare Mentorship — see RegisterSpeakerEndpoint for why it is a + // TypedResults value and not a (body, IResult) tuple. + [WolverinePost("/api/mentorships")] + public static Created Post( + RequestMentorship command, + [Entity("MentorId", Required = true)] Speaker mentor, + [Entity("MenteeId", Required = true)] Speaker mentee, + IDocumentSession session) + { + var mentorship = new Mentorship + { + Id = Guid.NewGuid(), + MentorId = mentor.Id, + MentorName = mentor.FullName, + MenteeId = mentee.Id, + MenteeName = mentee.FullName, + Status = MentorshipStatus.Pending, + Type = command.Type, + FocusAreas = command.FocusAreas ?? [], + RequestMessage = command.RequestMessage, + PreferredFrequency = command.PreferredFrequency, + RequestedAt = DateTimeOffset.UtcNow, + }; + + session.Store(mentorship); + return TypedResults.Created($"/api/mentorships/{mentorship.Id}", mentorship); + } +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Mentorships/RespondToMentorship.cs b/src/Samples/Bobcat/MoreSpeakers/Mentorships/RespondToMentorship.cs new file mode 100644 index 000000000..ba4772a85 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Mentorships/RespondToMentorship.cs @@ -0,0 +1,90 @@ +using Marten; +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Mentorships; + +public record AcceptMentorship(Guid MentorshipId, string? ResponseMessage); + +public static class AcceptMentorshipEndpoint +{ + public static ProblemDetails Validate([Entity("MentorshipId")] Mentorship? mentorship) + { + if (mentorship is null) + return new ProblemDetails { Detail = "Mentorship not found", Status = 404 }; + if (mentorship.Status != MentorshipStatus.Pending) + return new ProblemDetails { Detail = $"Cannot accept a mentorship in '{mentorship.Status}' status", Status = 400 }; + return WolverineContinue.NoProblems; + } + + [WolverinePost("/api/mentorships/{mentorshipId}/accept")] + public static Mentorship Post( + AcceptMentorship command, + [Entity("MentorshipId", Required = true)] Mentorship mentorship, + IDocumentSession session) + { + mentorship.Status = MentorshipStatus.Active; + mentorship.ResponseMessage = command.ResponseMessage; + mentorship.RespondedAt = DateTimeOffset.UtcNow; + mentorship.StartedAt = DateTimeOffset.UtcNow; + + session.Store(mentorship); + return mentorship; + } +} + +public record DeclineMentorship(Guid MentorshipId, string? ResponseMessage); + +public static class DeclineMentorshipEndpoint +{ + public static ProblemDetails Validate([Entity("MentorshipId")] Mentorship? mentorship) + { + if (mentorship is null) + return new ProblemDetails { Detail = "Mentorship not found", Status = 404 }; + if (mentorship.Status != MentorshipStatus.Pending) + return new ProblemDetails { Detail = $"Cannot decline a mentorship in '{mentorship.Status}' status", Status = 400 }; + return WolverineContinue.NoProblems; + } + + [WolverinePost("/api/mentorships/{mentorshipId}/decline")] + public static Mentorship Post( + DeclineMentorship command, + [Entity("MentorshipId", Required = true)] Mentorship mentorship, + IDocumentSession session) + { + mentorship.Status = MentorshipStatus.Declined; + mentorship.ResponseMessage = command.ResponseMessage; + mentorship.RespondedAt = DateTimeOffset.UtcNow; + + session.Store(mentorship); + return mentorship; + } +} + +public record CompleteMentorship(Guid MentorshipId); + +public static class CompleteMentorshipEndpoint +{ + public static ProblemDetails Validate([Entity("MentorshipId")] Mentorship? mentorship) + { + if (mentorship is null) + return new ProblemDetails { Detail = "Mentorship not found", Status = 404 }; + if (mentorship.Status != MentorshipStatus.Active) + return new ProblemDetails { Detail = "Only active mentorships can be completed", Status = 400 }; + return WolverineContinue.NoProblems; + } + + [WolverinePost("/api/mentorships/{mentorshipId}/complete")] + public static Mentorship Post( + CompleteMentorship command, + [Entity("MentorshipId", Required = true)] Mentorship mentorship, + IDocumentSession session) + { + mentorship.Status = MentorshipStatus.Completed; + mentorship.CompletedAt = DateTimeOffset.UtcNow; + + session.Store(mentorship); + return mentorship; + } +} diff --git a/src/Samples/Bobcat/MoreSpeakers/MoreSpeakers.csproj b/src/Samples/Bobcat/MoreSpeakers/MoreSpeakers.csproj new file mode 100644 index 000000000..dca252f58 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/MoreSpeakers.csproj @@ -0,0 +1,34 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/MoreSpeakers/MoreSpeakers.slnx b/src/Samples/Bobcat/MoreSpeakers/MoreSpeakers.slnx new file mode 100644 index 000000000..edaebd63c --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/MoreSpeakers.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Samples/Bobcat/MoreSpeakers/Program.cs b/src/Samples/Bobcat/MoreSpeakers/Program.cs new file mode 100644 index 000000000..f0209c26f --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Program.cs @@ -0,0 +1,53 @@ +using Marten; +using Wolverine; +using Wolverine.FluentValidation; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=more_speakers;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "speakers"; + + opts.Schema.For().Index(x => x.Email); + opts.Schema.For().Index(x => x.Expertise); + opts.Schema.For().Index(x => x.MentorId); + opts.Schema.For().Index(x => x.MenteeId); + opts.Schema.For().Index(x => x.Status); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); + opts.ServiceName = "MoreSpeakers"; +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +await app.RunAsync(); diff --git a/src/Samples/Bobcat/MoreSpeakers/README.md b/src/Samples/Bobcat/MoreSpeakers/README.md new file mode 100644 index 000000000..9af9c9d2f --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/README.md @@ -0,0 +1,65 @@ +# MoreSpeakers (Document DB) — Critter Stack Conversion + +## Original Project + +**Repository:** [cwoodruff/morespeakers-com](https://github.com/cwoodruff/morespeakers-com) +**License:** MIT +**Stack:** ASP.NET Core 10, Razor Pages, EF Core (SQL Server), ASP.NET Identity, AutoMapper, Azure Functions, .NET Aspire + +A mentorship platform connecting aspiring speakers with experienced mentors. 7 projects across 5 layers (Domain, Data, Managers, Web, Functions) with Razor Pages UI, HTMX interactivity, and Azure-first infrastructure. + +> **See also:** `MoreSpeakersES/` (coming) — the same domain rebuilt with Marten event sourcing and aggregate handlers. + +## What Changed + +### Removed (5 layers collapsed to 1) +- **Domain project** — separate entity models +- **Data project** — EF Core DbContext, DataStores (repositories), AutoMapper profiles, entity configurations +- **Managers project** — thin business logic wrappers with logging/telemetry +- **Web project** — Razor Pages UI (API endpoints preserved) +- **Functions project** — Azure Functions for email +- **AutoMapper** — Domain ↔ Data model mapping +- **EF Core** — SQL Server, migrations, join tables (UserExpertise, MentorshipExpertise) +- **ASP.NET Identity** — IdentityUser, roles, policies + +### Simplification Highlights + +| Aspect | Original | Converted | +|--------|----------|-----------| +| Projects | 7 | 1 (+1 test) | +| Layers per operation | 5 (Endpoint → Manager → DataStore → AutoMapper → EF Core) | 1 (Endpoint → Marten session) | +| Join tables | 3 (UserExpertise, MentorshipExpertise, UserSocialMediaSite) | 0 (nested collections) | +| Entity mapping | AutoMapper profiles | None needed | +| Database | SQL Server + Azure SQL | PostgreSQL (Marten) | + +### Document Model + +The original used 16 EF Core entities with join tables for many-to-many relationships. Marten stores these as documents with nested collections: + +- **Speaker** — embeds `Expertise` (string list), `SocialLinks` (nested objects). Replaces: User + UserExpertise + UserSocialMediaSite + SpeakerType. +- **Mentorship** — embeds `FocusAreas` (string list), denormalized mentor/mentee names. Replaces: Mentorship + MentorshipExpertise join table. +- **ExpertiseCategory** — embeds `Skills` (string list). Replaces: Sector + ExpertiseCategory + Expertise 3-level hierarchy. + +## Running + +Requires PostgreSQL. + +```bash +dotnet run +``` + +Specs (requires PostgreSQL -- `cd .. && docker compose up -d` starts the samples' instance on +5433 with a `more_speakers` database): + +```bash +dotnet test --project Tests # the specs +BOBCAT_SPEC_CONSOLE=1 dotnet run --project Tests # ...and print every spec as it rendered +``` + +`Tests/` holds Bobcat's projected (code-first) specs: ordinary xUnit v3 tests that render as +Given/When/Then specifications, driving the host through Alba. `SpeakerSpecs` covers speaker +registration and profiles, `MentorshipSpecs` the mentorship lifecycle, and every step is a method with +a `[Given]`, `[When]` or `[Then]` attribute on `MoreSpeakersSpec`, which also empties the store before +each spec -- registration refuses a duplicate email, and every spec registers under a fixed address. + +Swagger UI at `/swagger`. diff --git a/src/Samples/Bobcat/MoreSpeakers/Speakers/GetSpeakers.cs b/src/Samples/Bobcat/MoreSpeakers/Speakers/GetSpeakers.cs new file mode 100644 index 000000000..52d514650 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Speakers/GetSpeakers.cs @@ -0,0 +1,41 @@ +using Marten; +using Marten.AspNetCore; +using Wolverine.Http; + +namespace Speakers; + +// Marten.AspNetCore streams raw JSON from PostgreSQL directly to the HTTP response +// without deserializing to C# objects and re-serializing — significant performance win. + +public static class GetSpeakersEndpoint +{ + [WolverineGet("/api/speakers")] + public static Task Get(IQuerySession session, HttpContext context) + => session.Query().OrderBy(s => s.LastName).WriteArray(context); +} + +public static class GetSpeakerByIdEndpoint +{ + [WolverineGet("/api/speakers/{id}")] + public static Task Get(Guid id, IQuerySession session, HttpContext context) + => session.Json.WriteById(id, context); +} + +public static class GetAvailableMentorsEndpoint +{ + [WolverineGet("/api/speakers/mentors")] + public static Task Get(IQuerySession session, HttpContext context) + => session.Query() + .Where(s => s.IsAvailableForMentoring && s.Type == SpeakerType.Experienced) + .OrderBy(s => s.LastName) + .WriteArray(context); +} + +public static class SearchSpeakersByExpertiseEndpoint +{ + [WolverineGet("/api/speakers/search")] + public static Task Get(string expertise, IQuerySession session, HttpContext context) + => session.Query() + .Where(s => s.Expertise.Contains(expertise)) + .WriteArray(context); +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Speakers/RegisterSpeaker.cs b/src/Samples/Bobcat/MoreSpeakers/Speakers/RegisterSpeaker.cs new file mode 100644 index 000000000..2ca721b13 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Speakers/RegisterSpeaker.cs @@ -0,0 +1,65 @@ +using FluentValidation; +using Marten; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; + +namespace Speakers; + +public record RegisterSpeaker( + string Email, + string FirstName, + string LastName, + SpeakerType Type, + string? Bio = null, + string? Goals = null, + List? Expertise = null +) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + } + } +} + +public static class RegisterSpeakerEndpoint +{ + public static async Task ValidateAsync(RegisterSpeaker command, IQuerySession session) + { + var exists = await session.Query().AnyAsync(s => s.Email == command.Email); + if (exists) + return new ProblemDetails { Detail = $"Speaker with email '{command.Email}' already registered", Status = 409 }; + + return WolverineContinue.NoProblems; + } + + // Created rather than a bare Speaker, so the response carries the 201 and Location a + // newly-created resource should. Returned as a TypedResults value directly — a + // (Speaker, IResult) tuple would be read by Wolverine.HTTP as (body, cascaded-message) + // and the IResult dispatched as a message with no handler. See docs/sample-wiring.md + // footgun 3. + [WolverinePost("/api/speakers")] + public static Created Post(RegisterSpeaker command, IDocumentSession session) + { + var speaker = new Speaker + { + Id = Guid.NewGuid(), + Email = command.Email, + FirstName = command.FirstName, + LastName = command.LastName, + Type = command.Type, + Bio = command.Bio, + Goals = command.Goals, + Expertise = command.Expertise ?? [], + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(speaker); + return TypedResults.Created($"/api/speakers/{speaker.Id}", speaker); + } +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Speakers/Speaker.cs b/src/Samples/Bobcat/MoreSpeakers/Speakers/Speaker.cs new file mode 100644 index 000000000..23ab274ff --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Speakers/Speaker.cs @@ -0,0 +1,38 @@ +namespace Speakers; + +/// +/// Speaker document stored in Marten. Replaces User (IdentityUser) + UserExpertise +/// join table + SpeakerType FK + UserSocialMediaSite table. +/// Marten stores expertise and social links as nested collections — no joins needed. +/// +public class Speaker +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName => $"{FirstName} {LastName}"; + public string? Bio { get; set; } + public string? Goals { get; set; } + public string? HeadshotUrl { get; set; } + public string? SessionizeUrl { get; set; } + public SpeakerType Type { get; set; } = SpeakerType.New; + public List Expertise { get; set; } = []; + public List SocialLinks { get; set; } = []; + + // Mentorship availability + public bool IsAvailableForMentoring { get; set; } + public int MaxMentees { get; set; } + public string? MentorshipFocus { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } +} + +public enum SpeakerType { New, Experienced } + +public class SocialLink +{ + public string Platform { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Speakers/UpdateSpeakerProfile.cs b/src/Samples/Bobcat/MoreSpeakers/Speakers/UpdateSpeakerProfile.cs new file mode 100644 index 000000000..37e786feb --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Speakers/UpdateSpeakerProfile.cs @@ -0,0 +1,58 @@ +using FluentValidation; +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Speakers; + +public record UpdateSpeakerProfile( + Guid SpeakerId, + string FirstName, + string LastName, + string? Bio, + string? Goals, + string? HeadshotUrl, + string? SessionizeUrl, + bool IsAvailableForMentoring, + int MaxMentees, + string? MentorshipFocus, + List? Expertise, + List? SocialLinks +) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.FirstName).NotEmpty(); + RuleFor(x => x.LastName).NotEmpty(); + RuleFor(x => x.MaxMentees).GreaterThanOrEqualTo(0); + } + } +} + +public static class UpdateSpeakerProfileEndpoint +{ + [WolverinePut("/api/speakers/{speakerId}")] + public static Speaker Put( + UpdateSpeakerProfile command, + [Entity("SpeakerId", Required = true)] Speaker speaker, + IDocumentSession session) + { + speaker.FirstName = command.FirstName; + speaker.LastName = command.LastName; + speaker.Bio = command.Bio; + speaker.Goals = command.Goals; + speaker.HeadshotUrl = command.HeadshotUrl; + speaker.SessionizeUrl = command.SessionizeUrl; + speaker.IsAvailableForMentoring = command.IsAvailableForMentoring; + speaker.MaxMentees = command.MaxMentees; + speaker.MentorshipFocus = command.MentorshipFocus; + speaker.Expertise = command.Expertise ?? []; + speaker.SocialLinks = command.SocialLinks ?? []; + speaker.UpdatedAt = DateTimeOffset.UtcNow; + + session.Store(speaker); + return speaker; + } +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/MoreSpeakers/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..7c3bd4ea9 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Tests/AssemblyAttributes.cs @@ -0,0 +1,24 @@ +using MoreSpeakers.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(MoreSpeakersHost))] + +// Every spec empties the store before it runs (see MoreSpeakersSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tells ASP.NET Core's WebApplicationFactory (which Alba uses) where the host's content root +// is. Without it, discovery walks up from the test assembly's bin directory and synthesizes +// `samples/MoreSpeakers/MoreSpeakers/` from the host assembly name — a folder that does not +// exist — and throws DirectoryNotFoundException. The Tests project lives INSIDE the host +// project rather than beside it, which is not the layout the default assumes. +// +// The path is relative to the test assembly's bin directory at run time: from +// `samples/MoreSpeakers/Tests/bin//net10.0/`, four levels up is +// `samples/MoreSpeakers/`, where the host's appsettings.json lives. +[assembly: WebApplicationFactoryContentRoot( + "MoreSpeakers", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/MoreSpeakers/Tests/MentorshipSpecs.cs b/src/Samples/Bobcat/MoreSpeakers/Tests/MentorshipSpecs.cs new file mode 100644 index 000000000..44e5c444f --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Tests/MentorshipSpecs.cs @@ -0,0 +1,80 @@ +namespace MoreSpeakers.Tests; + +/// The mentorship lifecycle -- requested, accepted, completed -- and the requests the host refuses. +[BobcatFeature("More Speakers: mentorships")] +public class MentorshipSpecs(MoreSpeakersHost app) : MoreSpeakersSpec(app) +{ + [BobcatSpec] + public async Task request_mentorship() + { + await GivenASpeaker("mentee@conf.com", "Mentee Speaker"); + await GivenAMentor("mentor@conf.com", "Mentor Speaker"); + + await WhenMentorshipIsRequested(); + + ThenTheResponseStatusIs(201); + ThenTheMentorshipIdIsReturned(); + await ThenTheStoredMentorshipStatusIs("Pending"); + } + + [BobcatSpec] + public async Task self_mentorship_returns_400() + { + await GivenASpeaker("self.mentor@conf.com", "Self Mentor"); + + await WhenSelfMentorshipIsRequested(); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task request_mentorship_from_a_speaker_who_is_not_mentoring_returns_400() + { + await GivenASpeaker("unavailable.mentee@conf.com", "Unavailable Mentee"); + await GivenASpeakerWhoIsNotMentoring("unavailable.mentor@conf.com", "Unavailable Mentor"); + + await WhenMentorshipIsRequested(); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task accept_mentorship() + { + await GivenASpeaker("accept.mentee@conf.com", "Accept Mentee"); + await GivenAMentor("accept.mentor@conf.com", "Accept Mentor"); + await GivenMentorshipWasRequested(); + + await WhenTheMentorAccepts(); + + ThenTheResponseStatusIs(200); + await ThenTheStoredMentorshipStatusIs("Active"); + } + + [BobcatSpec] + public async Task accept_non_pending_mentorship_returns_400() + { + await GivenASpeaker("nonpending.mentee@conf.com", "NonPending Mentee"); + await GivenAMentor("nonpending.mentor@conf.com", "NonPending Mentor"); + await GivenMentorshipWasRequested(); + await GivenTheMentorAccepted(); + + await WhenTheMentorAccepts(); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task complete_mentorship() + { + await GivenASpeaker("complete.mentee@conf.com", "Complete Mentee"); + await GivenAMentor("complete.mentor@conf.com", "Complete Mentor"); + await GivenMentorshipWasRequested(); + await GivenTheMentorAccepted(); + + await WhenTheMentorCompletes(); + + ThenTheResponseStatusIs(200); + await ThenTheStoredMentorshipStatusIs("Completed"); + } +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Tests/MoreSpeakersHost.cs b/src/Samples/Bobcat/MoreSpeakers/Tests/MoreSpeakersHost.cs new file mode 100644 index 000000000..48f10443c --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Tests/MoreSpeakersHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace MoreSpeakers.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class MoreSpeakersHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Tests/MoreSpeakersSpec.cs b/src/Samples/Bobcat/MoreSpeakers/Tests/MoreSpeakersSpec.cs new file mode 100644 index 000000000..652e0629b --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Tests/MoreSpeakersSpec.cs @@ -0,0 +1,248 @@ +using Alba; +using Bobcat.CritterStack; +using Mentorships; +using Speakers; + +namespace MoreSpeakers.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every MoreSpeakers spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// The status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a spec +/// asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +public abstract class MoreSpeakersSpec(MoreSpeakersHost app) : IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // Load-bearing: POST /api/speakers refuses a duplicate email with a 409, and every spec registers speakers under fixed addresses. Without the reset the suite passes once per database and then every registration is a 409 for a speaker it believes is new. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } + + // ---- the MoreSpeakers vocabulary ------------------------------------------------------- + // + // Shared by SpeakerSpecs and MentorshipSpecs. Reuses the host's own command and document types via the + // project reference rather than re-declaring request records locally. + + private Speaker? _speaker; + private Guid _speakerId; + private Guid _mentorId; + private Guid _mentorshipId; + private int _lastStatusCode; + private List _speakers = []; + + [Given("a speaker registered with email \"{email}\" and name \"{name}\"")] + public Task GivenASpeaker(string email, string name) => registerSpeaker(email, name); + + [When("a speaker registers with email \"{email}\" and name \"{name}\"")] + public Task WhenASpeakerRegisters(string email, string name) => registerSpeaker(email, name); + + private async Task registerSpeaker(string email, string name) + { + var (first, last) = splitName(name); + var result = await PostJsonAsync( + "/api/speakers", new RegisterSpeaker(email, first, last, SpeakerType.New)); + + _lastStatusCode = result.StatusCode; + + // The first speaker a spec registers is "the speaker" every later step refers to; the + // duplicate-email spec's second call is a 409 whose ProblemDetails body must not displace it. + if (result.StatusCode == 201 && result.Body is not null && _speakerId == Guid.Empty) + { + _speaker = result.Body; + _speakerId = result.Body.Id; + } + } + + /// + /// A mentor is a speaker the host will let others request mentorship from, and + /// RequestMentorshipEndpoint.Validate refuses with a 400 unless IsAvailableForMentoring is + /// set. Registration has no such flag -- it is a profile attribute -- so registering a mentor is a POST + /// followed by the PUT that opts them in, as an experienced speaker. + /// + [Given("a mentor registered with email \"{email}\" and name \"{name}\"")] + public async Task GivenAMentor(string email, string name) + { + var (first, last) = splitName(name); + var registered = await PostJsonAsync( + "/api/speakers", new RegisterSpeaker(email, first, last, SpeakerType.Experienced)); + + _lastStatusCode = registered.StatusCode; + if (registered.Body is null) return; + + var mentor = registered.Body; + var optIn = await PutJsonAsync( + $"/api/speakers/{mentor.Id}", + profileFor(mentor) with { IsAvailableForMentoring = true, MaxMentees = 3 }); + + _lastStatusCode = optIn.StatusCode; + _mentorId = mentor.Id; + } + + /// The counter-case: a speaker who exists but never opted in to mentoring. + [Given("a speaker who is not mentoring registered with email \"{email}\" and name \"{name}\"")] + public async Task GivenASpeakerWhoIsNotMentoring(string email, string name) + { + var (first, last) = splitName(name); + var result = await PostJsonAsync( + "/api/speakers", new RegisterSpeaker(email, first, last, SpeakerType.Experienced)); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _mentorId = result.Body.Id; + } + + [When("all speakers are read")] + public async Task WhenAllSpeakersAreRead() + { + var result = await GetJsonAsync>("/api/speakers"); + _lastStatusCode = result.StatusCode; + _speakers = result.Body ?? []; + } + + [When("the speaker with id {id} is read")] + public async Task WhenTheSpeakerIsRead(Guid id) + { + var result = await GetJsonAsync($"/api/speakers/{id}"); + _lastStatusCode = result.StatusCode; + } + + [When("the speaker's bio is updated to \"{bio}\"")] + public async Task WhenTheSpeakersBioIsUpdated(string bio) + { + // The command is the whole profile, whose validator requires first and last name, so the step + // replays the speaker it registered with the one field changed. + var result = await PutJsonAsync( + $"/api/speakers/{_speakerId}", profileFor(_speaker!) with { Bio = bio }); + + _lastStatusCode = result.StatusCode; + } + + [Given("the speaker has requested mentorship from the mentor")] + public Task GivenMentorshipWasRequested() => requestMentorship(_mentorId); + + [When("the speaker requests mentorship from the mentor")] + public Task WhenMentorshipIsRequested() => requestMentorship(_mentorId); + + [When("the speaker requests mentorship from themselves")] + public Task WhenSelfMentorshipIsRequested() => requestMentorship(_speakerId); + + private async Task requestMentorship(Guid mentorId) + { + var result = await PostJsonAsync( + "/api/mentorships", + new RequestMentorship(mentorId, _speakerId, MentorshipType.NewToExperienced, + "Help me with my first talk", ["Distributed systems"], "Weekly")); + + _lastStatusCode = result.StatusCode; + if (result.StatusCode == 201 && result.Body is not null) _mentorshipId = result.Body.Id; + } + + [Given("the mentor has accepted the mentorship")] + public Task GivenTheMentorAccepted() => acceptMentorship(); + + [When("the mentor accepts the mentorship")] + public Task WhenTheMentorAccepts() => acceptMentorship(); + + private async Task acceptMentorship() + { + var result = await PostJsonAsync( + $"/api/mentorships/{_mentorshipId}/accept", new AcceptMentorship(_mentorshipId, "Happy to help")); + + _lastStatusCode = result.StatusCode; + } + + [When("the mentor completes the mentorship")] + public async Task WhenTheMentorCompletes() + { + var result = await PostJsonAsync( + $"/api/mentorships/{_mentorshipId}/complete", new CompleteMentorship(_mentorshipId)); + + _lastStatusCode = result.StatusCode; + } + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + [Then("the speaker id is returned")] + public void ThenTheSpeakerIdIsReturned() => SpecAssert.Fact(_speakerId != Guid.Empty, "No speaker id came back."); + + [Then("the mentorship id is returned")] + public void ThenTheMentorshipIdIsReturned() => SpecAssert.Fact(_mentorshipId != Guid.Empty, "No mentorship id came back."); + + [Then("at least {count} speaker is returned")] + public void ThenAtLeastSpeakersAreReturned(int count) + => SpecAssert.Fact(_speakers.Count >= count, $"Expected at least {count} speaker(s), got {_speakers.Count}."); + + [Then("the speaker list contains \"{email}\"")] + public void ThenTheSpeakerListContains(string email) + => SpecAssert.Fact(_speakers.Any(s => s.Email == email), + $"The speakers are [{string.Join(", ", _speakers.Select(s => s.Email))}], expected '{email}'."); + + /// + /// Reads the speaker back rather than trusting the write's response body -- the POST echoes what it + /// was about to store, and a sample whose point is "Marten stores the document" should show the + /// document came back out. + /// + [Then("the stored speaker is named \"{name}\" with email \"{email}\"")] + public async Task ThenTheStoredSpeakerIs(string name, string email) + { + var speaker = (await GetJsonAsync($"/api/speakers/{_speakerId}")).Body; + SpecAssert.Check("name", speaker?.FullName, name); + SpecAssert.Check("email", speaker?.Email, email); + } + + [Then("the stored speaker's bio is \"{bio}\"")] + public async Task ThenTheStoredSpeakersBioIs(string bio) + => SpecAssert.Check("bio", (await GetJsonAsync($"/api/speakers/{_speakerId}")).Body?.Bio, bio); + + [Then("the stored mentorship status is {status}")] + public async Task ThenTheStoredMentorshipStatusIs(string status) + => SpecAssert.Check("status", + (await GetJsonAsync($"/api/mentorships/{_mentorshipId}")).Body?.Status.ToString(), status); + + /// A speaker's name as one string ("Alice Speaker"): first word first, the rest last. + private static (string First, string Last) splitName(string name) + { + var parts = name.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + return parts.Length == 2 ? (parts[0], parts[1]) : (name, name); + } + + /// The full-profile PUT for a speaker as stored, so a step can change one field with with. + private static UpdateSpeakerProfile profileFor(Speaker speaker) => new( + speaker.Id, speaker.FirstName, speaker.LastName, speaker.Bio, speaker.Goals, speaker.HeadshotUrl, + speaker.SessionizeUrl, speaker.IsAvailableForMentoring, speaker.MaxMentees, speaker.MentorshipFocus, + speaker.Expertise, speaker.SocialLinks); +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Tests/SpeakerSpecs.cs b/src/Samples/Bobcat/MoreSpeakers/Tests/SpeakerSpecs.cs new file mode 100644 index 000000000..98978c44c --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Tests/SpeakerSpecs.cs @@ -0,0 +1,56 @@ +namespace MoreSpeakers.Tests; + +/// Speaker registration and profiles, driven over HTTP. +[BobcatFeature("More Speakers: speakers")] +public class SpeakerSpecs(MoreSpeakersHost app) : MoreSpeakersSpec(app) +{ + [BobcatSpec] + public async Task register_a_speaker() + { + await WhenASpeakerRegisters("speaker@conf.com", "Alice Speaker"); + + ThenTheResponseStatusIs(201); + ThenTheSpeakerIdIsReturned(); + await ThenTheStoredSpeakerIs("Alice Speaker", "speaker@conf.com"); + } + + [BobcatSpec] + public async Task register_duplicate_email_returns_409() + { + await GivenASpeaker("dup.speaker@conf.com", "Dup Speaker"); + + await WhenASpeakerRegisters("dup.speaker@conf.com", "Dup Speaker Again"); + + ThenTheResponseStatusIs(409); + } + + [BobcatSpec] + public async Task get_all_speakers() + { + await GivenASpeaker("list.speaker@conf.com", "Listed Speaker"); + + await WhenAllSpeakersAreRead(); + + ThenAtLeastSpeakersAreReturned(1); + ThenTheSpeakerListContains("list.speaker@conf.com"); + } + + [BobcatSpec] + public async Task get_speaker_by_id_returns_404_for_missing() + { + await WhenTheSpeakerIsRead(Guid.Empty); + + ThenTheResponseStatusIs(404); + } + + [BobcatSpec] + public async Task update_speaker_profile() + { + await GivenASpeaker("update.speaker@conf.com", "Update Speaker"); + + await WhenTheSpeakersBioIsUpdated("Expert in distributed systems"); + + ThenTheResponseStatusIs(200); + await ThenTheStoredSpeakersBioIs("Expert in distributed systems"); + } +} diff --git a/src/Samples/Bobcat/MoreSpeakers/Tests/Tests.csproj b/src/Samples/Bobcat/MoreSpeakers/Tests/Tests.csproj new file mode 100644 index 000000000..5db6bad50 --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + MoreSpeakers.Tests + MoreSpeakers.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/MoreSpeakers/Tests/Usings.cs b/src/Samples/Bobcat/MoreSpeakers/Tests/Usings.cs new file mode 100644 index 000000000..c17dbd16f --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/Tests/Usings.cs @@ -0,0 +1,3 @@ +global using Bobcat; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/MoreSpeakers/appsettings.json b/src/Samples/Bobcat/MoreSpeakers/appsettings.json new file mode 100644 index 000000000..d76849fad --- /dev/null +++ b/src/Samples/Bobcat/MoreSpeakers/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=more_speakers;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/OutboxDemo/AddEventAttendee.cs b/src/Samples/Bobcat/OutboxDemo/AddEventAttendee.cs new file mode 100644 index 000000000..114e567e3 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/AddEventAttendee.cs @@ -0,0 +1,18 @@ +namespace OutboxDemo; + +/// +/// Sent by the saga to add the member as an event attendee. +/// +public record AddEventAttendee( + Guid RegistrationId, + string MemberId, + string EventId +); + +public static class AddEventAttendeeHandler +{ + public static void Handle(AddEventAttendee message) + { + Console.WriteLine($"[AddAttendee] Added member {message.MemberId} to event {message.EventId}"); + } +} diff --git a/src/Samples/Bobcat/OutboxDemo/OutboxDemo.csproj b/src/Samples/Bobcat/OutboxDemo/OutboxDemo.csproj new file mode 100644 index 000000000..c82d38b87 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/OutboxDemo.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/OutboxDemo/OutboxDemo.slnx b/src/Samples/Bobcat/OutboxDemo/OutboxDemo.slnx new file mode 100644 index 000000000..b4a67a87e --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/OutboxDemo.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Samples/Bobcat/OutboxDemo/Program.cs b/src/Samples/Bobcat/OutboxDemo/Program.cs new file mode 100644 index 000000000..5db975df3 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/Program.cs @@ -0,0 +1,52 @@ +using Marten; +using Wolverine; +using Wolverine.Http; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=outbox_demo;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + opts.DatabaseSchemaName = "outbox_demo"; + + opts.Schema.For() + // Watch this syntax. Marten does this by making you specify a separate expression for each property + // rather than using an anonymous type + .UniqueIndex(r => r.MemberId, r => r.EventId); +}) +// IntegrateWithWolverine() enables the Marten outbox — messages published during +// a handler or HTTP request are stored in the same Marten transaction and delivered +// after commit. This replaces MassTransit's EntityFramework outbox entirely. +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + + // Wolverine's durable inbox/outbox with Marten persistence + // replaces MassTransit's BusOutbox + InboxState + OutboxMessage tables + opts.Durability.Mode = DurabilityMode.Solo; +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(); + +await app.RunAsync(); diff --git a/src/Samples/Bobcat/OutboxDemo/README.md b/src/Samples/Bobcat/OutboxDemo/README.md new file mode 100644 index 000000000..c444410f7 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/README.md @@ -0,0 +1,88 @@ +# Outbox Demo — Critter Stack Conversion + +## Original Project + +**Repository:** [MassTransit/Sample-Outbox](https://github.com/MassTransit/Sample-Outbox) +**License:** Apache 2.0 +**Stack:** MassTransit 8, EF Core (PostgreSQL), RabbitMQ, ASP.NET Core, OpenTelemetry/Jaeger + +Demonstrates MassTransit's transactional outbox pattern: business data and messages are written in the same database transaction, guaranteeing at-least-once delivery without distributed transactions. Includes a state machine saga that orchestrates a multi-step registration workflow. + +**Domain:** Event registration — a member registers for an event, triggering email notification, attendee tracking, and validation. + +## What Changed + +### Removed (3 projects collapsed to 1) +- **Sample.Api** — ASP.NET Core Web API with MVC controller +- **Sample.Components** — shared library with consumers, saga, services, contracts +- **Sample.Worker** — background service for consumer processing +- **MassTransit** — consumers, state machine saga, outbox configuration, consumer definitions +- **EF Core** — DbContext, entity configurations, saga state mapping, outbox/inbox tables +- **Separate worker process** — MassTransit required a separate worker for consumer hosting + +### Added +- **WolverineFx.Marten** — integrated outbox (messages + documents in same transaction) +- **Wolverine handlers** — static methods replacing MassTransit consumers +- **Cascade messages** — handler return values automatically published via outbox + +### Before vs After + +| Aspect | Original (MassTransit) | Converted (Wolverine + Marten) | +|--------|----------------------|-------------------------------| +| Projects | 3 (Api, Components, Worker) | 1 | +| C# files | ~25 | 5 | +| Outbox config | EF Core interceptor + BusOutbox + polling interval | `IntegrateWithWolverine()` (one line) | +| Saga | State machine class + state entity + definition + map | Handler returning cascade tuple | +| Consumer | `IConsumer` implementation class + definition class | Static `Handle(T)` method | +| Worker process | Separate hosted service | Built into web host | +| Inbox/Outbox tables | 4 MassTransit-managed tables | Wolverine envelope tables (auto-created) | + +### The Outbox Comparison + +**MassTransit outbox setup (spread across multiple files):** +```csharp +// In each host: +x.AddEntityFrameworkOutbox(o => { + o.QueryDelay = TimeSpan.FromSeconds(1); + o.UsePostgres(); + o.UseBusOutbox(); +}); +// Plus: DbContext extends SagaDbContext, outbox tables in migrations +``` + +**Wolverine+Marten outbox setup (one line):** +```csharp +builder.Services.AddMarten(opts => { ... }) + .IntegrateWithWolverine(); // That's it. Outbox is built in. +``` + +### Saga Simplification + +The original MassTransit saga required 4 files: +- `RegistrationStateMachine.cs` — state machine definition with transitions +- `RegistrationState.cs` — saga state entity +- `RegistrationStateDefinition.cs` — consumer retry and outbox config +- `RegistrationStateMap.cs` — EF Core mapping for saga state + +The converted version is a single handler method that returns a tuple of cascade messages: +```csharp +public static (SendRegistrationEmail, AddEventAttendee) Handle( + RegistrationSubmitted message, IDocumentSession session) +``` + +Wolverine sends both cascade messages via the Marten outbox in the same transaction. + +## Running + +Requires PostgreSQL. Update the connection string in `appsettings.json`, then: + +```bash +dotnet run +``` + +POST to `/registration` with: +```json +{ "eventId": "conf-2026", "memberId": "member-1", "payment": 100.00 } +``` + +Swagger UI available at `/swagger`. diff --git a/src/Samples/Bobcat/OutboxDemo/Registration.cs b/src/Samples/Bobcat/OutboxDemo/Registration.cs new file mode 100644 index 000000000..06d8f3de2 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/Registration.cs @@ -0,0 +1,45 @@ +using Wolverine; + +namespace OutboxDemo; + +/// +/// Published when a new registration is submitted. +/// Triggers the saga workflow. +/// +public record RegistrationSubmitted( + Guid RegistrationId, + DateTime RegistrationDate, + string MemberId, + string EventId, + decimal Payment +); + +/// +/// Registration document stored in Marten. +/// +public class Registration : Saga +{ + public Guid Id { get; set; } + public DateTime RegistrationDate { get; set; } + public string MemberId { get; set; } = string.Empty; + public string EventId { get; set; } = string.Empty; + public decimal Payment { get; set; } + public string CurrentState { get; set; } = "Started"; + + public (SendRegistrationEmail, AddEventAttendee) Handle(RegistrationSubmitted message) + { + // Just to show state changes + CurrentState = "Submitted"; + + // Cascade messages — sent via the Marten outbox in the same transaction + return ( + new SendRegistrationEmail(message.RegistrationId, message.RegistrationDate, message.MemberId, message.EventId), + new AddEventAttendee(message.RegistrationId, message.MemberId, message.EventId) + ); + } + + public void Handle(RegistrationValidated validated) + { + CurrentState = "Validated"; + } +} diff --git a/src/Samples/Bobcat/OutboxDemo/RegistrationValidated.cs b/src/Samples/Bobcat/OutboxDemo/RegistrationValidated.cs new file mode 100644 index 000000000..8bee77f40 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/RegistrationValidated.cs @@ -0,0 +1,14 @@ +namespace OutboxDemo; + +/// +/// Published after registration validation completes. +/// +public record RegistrationValidated(Guid RegistrationId); + +public static class RegistrationValidatedHandler +{ + public static void Handle(RegistrationValidated message) + { + Console.WriteLine($"[RegistrationValidated] Registration {message.RegistrationId} validated successfully"); + } +} diff --git a/src/Samples/Bobcat/OutboxDemo/SendRegistrationEmail.cs b/src/Samples/Bobcat/OutboxDemo/SendRegistrationEmail.cs new file mode 100644 index 000000000..16f2fdda5 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/SendRegistrationEmail.cs @@ -0,0 +1,19 @@ +namespace OutboxDemo; + +/// +/// Sent by the saga to trigger email notification. +/// +public record SendRegistrationEmail( + Guid RegistrationId, + DateTime RegistrationDate, + string MemberId, + string EventId +); + +public static class SendRegistrationEmailHandler +{ + public static void Handle(SendRegistrationEmail message) + { + Console.WriteLine($"[SendEmail] Registration email sent to member {message.MemberId} for event {message.EventId}"); + } +} diff --git a/src/Samples/Bobcat/OutboxDemo/SubmitRegistration.cs b/src/Samples/Bobcat/OutboxDemo/SubmitRegistration.cs new file mode 100644 index 000000000..e473b5276 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/SubmitRegistration.cs @@ -0,0 +1,56 @@ +using Marten; +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; + +namespace OutboxDemo; + +public record SubmitRegistration(string EventId, string MemberId, decimal Payment); + +public static class SubmitRegistrationEndpoint +{ + // Sad path: duplicate registration check + public static async Task ValidateAsync( + SubmitRegistration command, + IQuerySession session) + { + var duplicate = await session.Query() + .AnyAsync(r => r.MemberId == command.MemberId && r.EventId == command.EventId); + + if (duplicate) + return new ProblemDetails + { + Detail = $"Duplicate registration for member {command.MemberId} at event {command.EventId}", + Status = 409, + }; + + return WolverineContinue.NoProblems; + } + + // Return Results.NoContent() as the HTTP response. + // Registration (Saga) and RegistrationSubmitted are cascading messages — + // Wolverine auto-persists the saga and publishes via the outbox. + [WolverinePost("/registration")] + public static (IResult, Registration, RegistrationSubmitted) Post( + SubmitRegistration command) + { + var registration = new Registration + { + Id = Guid.NewGuid(), + RegistrationDate = DateTime.UtcNow, + MemberId = command.MemberId, + EventId = command.EventId, + Payment = command.Payment, + }; + + return ( + Results.NoContent(), + registration, + new RegistrationSubmitted( + registration.Id, + registration.RegistrationDate, + registration.MemberId, + registration.EventId, + registration.Payment) + ); + } +} diff --git a/src/Samples/Bobcat/OutboxDemo/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/OutboxDemo/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..13172fb6f --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/Tests/AssemblyAttributes.cs @@ -0,0 +1,24 @@ +using OutboxDemo.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(OutboxDemoHost))] + +// Every spec empties the store before it runs (see OutboxDemoSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tells ASP.NET Core's WebApplicationFactory (which Alba uses) where the host's content root +// is. Without it, discovery walks up from the test assembly's bin directory and synthesizes +// `samples/OutboxDemo/OutboxDemo/` from the host assembly name — a folder that does not exist — +// and throws DirectoryNotFoundException. The Tests project lives INSIDE the host project rather +// than beside it, which is not the layout the default assumes. +// +// The path is relative to the test assembly's bin directory at run time: from +// `samples/OutboxDemo/Tests/bin//net10.0/`, four levels up is `samples/OutboxDemo/`, +// where the host's appsettings.json lives. +[assembly: WebApplicationFactoryContentRoot( + "OutboxDemo", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/OutboxDemo/Tests/OutboxDemoHost.cs b/src/Samples/Bobcat/OutboxDemo/Tests/OutboxDemoHost.cs new file mode 100644 index 000000000..1fdbb30f5 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/Tests/OutboxDemoHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace OutboxDemo.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class OutboxDemoHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/OutboxDemo/Tests/OutboxDemoSpec.cs b/src/Samples/Bobcat/OutboxDemo/Tests/OutboxDemoSpec.cs new file mode 100644 index 000000000..1ce304728 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/Tests/OutboxDemoSpec.cs @@ -0,0 +1,74 @@ +using Alba; +using Bobcat.CritterStack; +using Wolverine; +using Wolverine.Tracking; + +namespace OutboxDemo.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every OutboxDemo spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// The status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a spec +/// asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +public abstract class OutboxDemoSpec(OutboxDemoHost app) : IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // Not optional: a Marten unique index on (MemberId, EventId) is persistent state, and the duplicate-registration spec leaves a Registration behind. Without the reset the suite passes once per database and then answers 409 for registrations it believes are new. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } + + /// + /// Run an HTTP call and wait for every message it cascades to be fully handled. The modules in this + /// sample talk over UseDurableInbox() local queues, so the response goes out before the next + /// module has handled the cascade; asserting straight off it would race the handler. + /// + protected async Task> TrackedAsync(Func>> call) + { + HttpResult? captured = null; + + // Explicitly typed: ExecuteAndWaitAsync overloads on Task and ValueTask, and an async lambda is + // convertible to both. + Func act = async _ => { captured = await call(); }; + + await Alba.TrackActivity() + .Timeout(TimeSpan.FromSeconds(30)) + .ExecuteAndWaitAsync(act); + + return captured!; + } +} diff --git a/src/Samples/Bobcat/OutboxDemo/Tests/RegistrationSpecs.cs b/src/Samples/Bobcat/OutboxDemo/Tests/RegistrationSpecs.cs new file mode 100644 index 000000000..42a6990a2 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/Tests/RegistrationSpecs.cs @@ -0,0 +1,87 @@ +namespace OutboxDemo.Tests; + +/// +/// The one endpoint this sample exposes: POST /registration, whose happy path returns 204 while +/// cascading a Registration saga and two messages through Marten's outbox in the same transaction, and +/// whose compound ValidateAsync handler rejects a duplicate registration with a 409. +/// +[BobcatFeature("Outbox Demo")] +public class RegistrationSpecs(OutboxDemoHost app) : OutboxDemoSpec(app) +{ + private int _lastStatusCode; + private string? _lastProblemDetail; + + [BobcatSpec] + public async Task a_new_registration_is_accepted() + { + await WhenARegistrationIsSubmitted("member-001", "event-001"); + + ThenTheResponseStatusIs(204); + } + + [BobcatSpec] + public async Task the_same_member_cannot_register_twice_for_one_event() + { + await GivenARegistration("dup-member", "dup-event"); + + await WhenARegistrationIsSubmitted("dup-member", "dup-event"); + + ThenTheResponseStatusIs(409); + ThenTheRejectionNamesTheDuplicateMember("dup-member"); + } + + [BobcatSpec] + public async Task the_same_member_may_register_for_a_different_event() + { + await GivenARegistration("multi-member", "event-a"); + + await WhenARegistrationIsSubmitted("multi-member", "event-b"); + + ThenTheResponseStatusIs(204); + } + + [BobcatSpec] + public async Task the_payment_rides_along_with_the_registration() + { + await WhenARegistrationIsSubmittedPaying("paying-member", "event-002", 250); + + ThenTheResponseStatusIs(204); + } + + // ---- steps ------------------------------------------------------------------------------ + + [Given("a registration for member \"{memberId}\" at event \"{eventId}\"")] + public Task GivenARegistration(string memberId, string eventId) => submit(memberId, eventId, 100m); + + [When("a registration is submitted for member \"{memberId}\" at event \"{eventId}\"")] + public Task WhenARegistrationIsSubmitted(string memberId, string eventId) => submit(memberId, eventId, 100m); + + [When("a registration is submitted for member \"{memberId}\" at event \"{eventId}\" paying {payment}")] + public Task WhenARegistrationIsSubmittedPaying(string memberId, string eventId, decimal payment) + => submit(memberId, eventId, payment); + + private async Task submit(string memberId, string eventId, decimal payment) + { + // Tracked: the saga and the two messages it cascades are handled after the response goes out, + // and a spec that left them in flight would have the next spec's reset delete the saga under them. + var result = await TrackedAsync(() => PostJsonAsync( + "/registration", new SubmitRegistration(eventId, memberId, payment))); + + _lastStatusCode = result.StatusCode; + _lastProblemDetail = result.Body?.Detail; + } + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + [Then("the rejection names the duplicate member \"{memberId}\"")] + public void ThenTheRejectionNamesTheDuplicateMember(string memberId) + => SpecAssert.Fact(_lastProblemDetail?.Contains(memberId, StringComparison.Ordinal) == true, + $"The rejection reads '{_lastProblemDetail}', which does not name '{memberId}'."); +} + +/// +/// Just enough of RFC 7807 to read the rejection back, so the spec project does not take an MVC +/// dependency to read one string. +/// +public record ProblemDetailsBody(string? Detail, int? Status); diff --git a/src/Samples/Bobcat/OutboxDemo/Tests/Tests.csproj b/src/Samples/Bobcat/OutboxDemo/Tests/Tests.csproj new file mode 100644 index 000000000..f3dd256b0 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + OutboxDemo.Tests + OutboxDemo.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/OutboxDemo/Tests/Usings.cs b/src/Samples/Bobcat/OutboxDemo/Tests/Usings.cs new file mode 100644 index 000000000..c17dbd16f --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/Tests/Usings.cs @@ -0,0 +1,3 @@ +global using Bobcat; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/OutboxDemo/appsettings.json b/src/Samples/Bobcat/OutboxDemo/appsettings.json new file mode 100644 index 000000000..84b41a0d3 --- /dev/null +++ b/src/Samples/Bobcat/OutboxDemo/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=outbox_demo;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Customers/CompleteCustomer.cs b/src/Samples/Bobcat/PaymentsMonolith/Customers/CompleteCustomer.cs new file mode 100644 index 000000000..b8f32c431 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Customers/CompleteCustomer.cs @@ -0,0 +1,56 @@ +using FluentValidation; +using Marten; +using PaymentsMonolith; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Customers; + +public record CompleteCustomer(Guid CustomerId, string Name, string FullName, string Nationality) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.FullName).NotEmpty(); + RuleFor(x => x.Nationality).NotEmpty(); + } + } +} + +public static class CompleteCustomerEndpoint +{ + [WolverinePut("/api/customers/{customerId}/complete")] + public static (Customer, CustomerCompleted) Put( + CompleteCustomer command, + [Entity("CustomerId", Required = true)] Customer customer, + IDocumentSession session) + { + customer.Name = command.Name; + customer.FullName = command.FullName; + customer.Nationality = command.Nationality; + customer.IsCompleted = true; + + session.Store(customer); + + return (customer, new CustomerCompleted(customer.Id, customer.Name, customer.FullName, customer.Nationality)); + } +} + +/// +/// Handles UserCreated from the Users module — creates a Customer stub. +/// +public static class UserCreatedHandler +{ + public static void Handle(UserCreated message, IDocumentSession session) + { + session.Store(new Customer + { + Id = message.UserId, + Email = message.Email, + FullName = message.FullName, + CreatedAt = DateTimeOffset.UtcNow, + }); + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Customers/Customer.cs b/src/Samples/Bobcat/PaymentsMonolith/Customers/Customer.cs new file mode 100644 index 000000000..9eab38d47 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Customers/Customer.cs @@ -0,0 +1,12 @@ +namespace Customers; + +public class Customer +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public string Nationality { get; set; } = string.Empty; + public bool IsCompleted { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Customers/GetCustomer.cs b/src/Samples/Bobcat/PaymentsMonolith/Customers/GetCustomer.cs new file mode 100644 index 000000000..06f827df7 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Customers/GetCustomer.cs @@ -0,0 +1,15 @@ +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Customers; + +/// +/// Reading a customer back was missing entirely — the module could only be written to, via the +/// UserCreated cascade and the completion endpoint. Without it there is no way to observe that +/// registering a user really does create the customer stub, which is the sample's whole point. +/// +public static class GetCustomerEndpoint +{ + [WolverineGet("/api/customers/{id}")] + public static Customer? Get(Guid id, [Entity] Customer? customer) => customer; +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/IntegrationEvents.cs b/src/Samples/Bobcat/PaymentsMonolith/IntegrationEvents.cs new file mode 100644 index 000000000..6d5043643 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/IntegrationEvents.cs @@ -0,0 +1,14 @@ +namespace PaymentsMonolith; + +// Integration events for cross-module communication via durable local queues. +// Replaces: InMemoryEventBus + OutboxBroker + OutboxProcessor + InboxEventHandlerDecorator +// + ModuleClient + ModuleRegistry + Contract validation + +public record UserCreated(Guid UserId, string Email, string FullName); +public record CustomerCompleted(Guid CustomerId, string Name, string FullName, string Nationality); +public record OwnerCreated(Guid OwnerId, string Name, string FullName); +public record WalletCreated(Guid WalletId, Guid OwnerId, string Currency); +public record FundsAdded(Guid WalletId, Guid OwnerId, string Currency, decimal Amount); +public record FundsDeducted(Guid WalletId, Guid OwnerId, string Currency, decimal Amount); +public record DepositCompleted(Guid DepositId, Guid CustomerId, string Currency, decimal Amount); +public record WithdrawalCompleted(Guid WithdrawalId, Guid CustomerId, string Currency, decimal Amount); diff --git a/src/Samples/Bobcat/PaymentsMonolith/Payments/Deposit.cs b/src/Samples/Bobcat/PaymentsMonolith/Payments/Deposit.cs new file mode 100644 index 000000000..f5674d509 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Payments/Deposit.cs @@ -0,0 +1,52 @@ +using FluentValidation; +using Marten; +using PaymentsMonolith; +using Wolverine.Http; + +namespace Payments; + +public class Deposit +{ + public Guid Id { get; set; } + public Guid CustomerId { get; set; } + public string Currency { get; set; } = string.Empty; + public decimal Amount { get; set; } + public DepositStatus Status { get; set; } = DepositStatus.Pending; + public DateTimeOffset CreatedAt { get; set; } +} + +public enum DepositStatus { Pending, Completed, Rejected } + +public record CreateDeposit(Guid CustomerId, string Currency, decimal Amount) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.CustomerId).NotEmpty(); + RuleFor(x => x.Currency).NotEmpty(); + RuleFor(x => x.Amount).GreaterThan(0); + } + } +} + +public static class CreateDepositEndpoint +{ + [WolverinePost("/api/payments/deposits")] + public static (Deposit, DepositCompleted) Post(CreateDeposit command, IDocumentSession session) + { + var deposit = new Deposit + { + Id = Guid.NewGuid(), + CustomerId = command.CustomerId, + Currency = command.Currency, + Amount = command.Amount, + Status = DepositStatus.Completed, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(deposit); + + return (deposit, new DepositCompleted(deposit.Id, deposit.CustomerId, deposit.Currency, deposit.Amount)); + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/PaymentsMonolith.csproj b/src/Samples/Bobcat/PaymentsMonolith/PaymentsMonolith.csproj new file mode 100644 index 000000000..f945c3a64 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/PaymentsMonolith.csproj @@ -0,0 +1,33 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/PaymentsMonolith/PaymentsMonolith.slnx b/src/Samples/Bobcat/PaymentsMonolith/PaymentsMonolith.slnx new file mode 100644 index 000000000..230dd01b9 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/PaymentsMonolith.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Samples/Bobcat/PaymentsMonolith/Program.cs b/src/Samples/Bobcat/PaymentsMonolith/Program.cs new file mode 100644 index 000000000..04bafb01e --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Program.cs @@ -0,0 +1,69 @@ +using Marten; +using PaymentsMonolith; +using Wolverine; +using Wolverine.FluentValidation; +using Wolverine.Http; +using Wolverine.Http.FluentValidation; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddWolverineHttp(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Marten") + ?? "Host=localhost;Port=5433;Database=inflow;Username=postgres;Password=postgres"; + + opts.Connection(connectionString); + + // Schema-per-module + opts.Schema.For().DatabaseSchemaName("users"); + opts.Schema.For().DatabaseSchemaName("customers"); + opts.Schema.For().DatabaseSchemaName("wallets"); + opts.Schema.For().Index(x => x.OwnerId); + opts.Schema.For().DatabaseSchemaName("payments"); + opts.Schema.For().Index(x => x.CustomerId); +}) +.IntegrateWithWolverine() +.UseLightweightSessions(); + +builder.Host.UseWolverine(opts => +{ + opts.Discovery.IncludeAssembly(typeof(Program).Assembly); + opts.Policies.AutoApplyTransactions(); + opts.UseFluentValidation(); + opts.ServiceName = "Inflow"; + + // Durable local queues for inter-module messaging + opts.LocalQueue("users").UseDurableInbox(); + opts.LocalQueue("customers").UseDurableInbox(); + opts.LocalQueue("wallets").UseDurableInbox(); + opts.LocalQueue("payments").UseDurableInbox(); + + // Route integration events to module-specific queues + opts.Publish(x => { x.Message(); x.ToLocalQueue("customers"); }); + opts.Publish(x => { x.Message(); x.ToLocalQueue("wallets"); }); + opts.Publish(x => { x.Message(); x.ToLocalQueue("payments"); }); + opts.Publish(x => { x.Message(); x.ToLocalQueue("payments"); }); + opts.Publish(x => { x.Message(); x.ToLocalQueue("payments"); }); + opts.Publish(x => { x.Message(); x.ToLocalQueue("wallets"); }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapWolverineEndpoints(opts => +{ + opts.UseFluentValidationProblemDetailMiddleware(); +}); + +await app.RunAsync(); diff --git a/src/Samples/Bobcat/PaymentsMonolith/README.md b/src/Samples/Bobcat/PaymentsMonolith/README.md new file mode 100644 index 000000000..39e9c548d --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/README.md @@ -0,0 +1,62 @@ +# Virtual Payments Monolith — Critter Stack Conversion + +## Original Project + +**Repository:** [devmentors/Inflow](https://github.com/devmentors/Inflow) +**License:** MIT +**Stack:** .NET 6, custom modular framework, EF Core (PostgreSQL), Chronicle sagas, custom outbox/inbox + +A virtual payments app built as a modular monolith with zero inter-module project references. Each module is fully encapsulated with its own architecture. Integration is purely event-driven through a custom event bus, outbox, and inbox infrastructure. + +**Domain:** Virtual payments — Users, Customers, Wallets (with transfers), Payments (deposits/withdrawals) + +## What Changed + +### Removed (custom modular framework) +- **20 projects** collapsed to 1 +- **InMemoryEventBus** — custom pub/sub dictionary + module subscriptions +- **OutboxBroker + OutboxProcessor** — custom outbox tables + background polling +- **InboxEventHandlerDecorator** — custom inbox for idempotency +- **InternalCommands** — queued command pattern + background processor +- **ModuleClient + ModuleRegistry** — synchronous inter-module RPC +- **Contract validation** — schema validation for integration events +- **Chronicle** — distributed saga orchestration library +- **Scrutor decorators** — transactional, logging, inbox decorators +- **Custom dispatchers** — CommandDispatcher, QueryDispatcher, EventDispatcher, DomainEventDispatcher +- **AsyncMessageDispatcher + MessageChannel** — custom async message queue +- **ModuleLoader** — assembly scanning + dynamic module discovery +- **EF Core** — DbContexts, migrations, repositories per module + +### Before vs After + +| Aspect | Original | Converted | +|--------|----------|-----------| +| Projects | 20 | 1 | +| Custom infrastructure files | ~40+ | 0 | +| Outbox/Inbox | Custom tables + background processors | Wolverine durable local queues | +| Event bus | InMemoryEventBus + ModuleClient | Wolverine message routing | +| Sagas | Chronicle library | Wolverine cascading messages | +| DI framework | Custom module discovery + Scrutor | Built-in .NET DI | +| Database | EF Core (PostgreSQL) | Marten (PostgreSQL) | + +### Module Communication + +``` +User Registration → UserCreated → Customers module (creates Customer stub) + ↓ +Customer Completion → CustomerCompleted → Wallets module (creates Wallet) + ↓ +Deposit/Transfer → FundsAdded/FundsDeducted → Payments module +``` + +All integration events flow through durable local queues — no custom outbox/inbox infrastructure needed. + +## Running + +Requires PostgreSQL only. + +```bash +dotnet run +``` + +Swagger UI at `/swagger`. diff --git a/src/Samples/Bobcat/PaymentsMonolith/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/PaymentsMonolith/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..eaa73f6d0 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Tests/AssemblyAttributes.cs @@ -0,0 +1,24 @@ +using PaymentsMonolith.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(PaymentsMonolithHost))] + +// Every spec empties the store before it runs (see PaymentsMonolithSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tells ASP.NET Core's WebApplicationFactory (which Alba uses) where the host's content root +// is. Without it, discovery walks up from the test assembly's bin directory and synthesizes +// `samples/PaymentsMonolith/PaymentsMonolith/` from the host assembly name — a folder that does +// not exist — and throws DirectoryNotFoundException. The Tests project lives INSIDE the host +// project rather than beside it, which is not the layout the default assumes. +// +// The path is relative to the test assembly's bin directory at run time: from +// `samples/PaymentsMonolith/Tests/bin//net10.0/`, four levels up is +// `samples/PaymentsMonolith/`, where the host's appsettings.json lives. +[assembly: WebApplicationFactoryContentRoot( + "PaymentsMonolith", + "../../../..", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsMonolithHost.cs b/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsMonolithHost.cs new file mode 100644 index 000000000..d4414b1ed --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsMonolithHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace PaymentsMonolith.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class PaymentsMonolithHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsMonolithSpec.cs b/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsMonolithSpec.cs new file mode 100644 index 000000000..2ae2de96b --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsMonolithSpec.cs @@ -0,0 +1,74 @@ +using Alba; +using Bobcat.CritterStack; +using Wolverine; +using Wolverine.Tracking; + +namespace PaymentsMonolith.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every PaymentsMonolith spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// The status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a spec +/// asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +public abstract class PaymentsMonolithSpec(PaymentsMonolithHost app) : IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // Not optional: registration is guarded by a uniqueness check on email, so every user a spec registers changes what the next run means. Without the reset the suite passes once per database and then answers 409 for registrations it believes are new. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } + + /// + /// Run an HTTP call and wait for every message it cascades to be fully handled. The modules in this + /// sample talk over UseDurableInbox() local queues, so the response goes out before the next + /// module has handled the cascade; asserting straight off it would race the handler. + /// + protected async Task> TrackedAsync(Func>> call) + { + HttpResult? captured = null; + + // Explicitly typed: ExecuteAndWaitAsync overloads on Task and ValueTask, and an async lambda is + // convertible to both. + Func act = async _ => { captured = await call(); }; + + await Alba.TrackActivity() + .Timeout(TimeSpan.FromSeconds(30)) + .ExecuteAndWaitAsync(act); + + return captured!; + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsSpecs.cs b/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsSpecs.cs new file mode 100644 index 000000000..a20c9ccea --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Tests/PaymentsSpecs.cs @@ -0,0 +1,299 @@ +using Customers; +using Payments; +using Users; +using Wallets; + +namespace PaymentsMonolith.Tests; + +/// +/// The Inflow conversion: four modules in one process that talk to each other only through durable +/// local queues. Registering a user makes the Customers module create a customer stub; completing that +/// profile makes the Wallets module create a wallet. Neither is a direct call, so these specs are as +/// much about the cascade arriving as about the endpoint returning -- which is why every write is +/// tracked until its cascades are handled. +/// +/// +/// Measured, not assumed: replacing the tracking with a plain call fails 3 of the 11 specs outright. +/// +[BobcatFeature("Payments Monolith")] +public class PaymentsSpecs(PaymentsMonolithHost app) : PaymentsMonolithSpec(app) +{ + private const string Password = "Password1!"; + + private Guid _customerId; + private Guid _walletId; + private Guid _secondCustomerId; + private Guid _secondWalletId; + private int _lastStatusCode; + private Wallet? _wallet; + private Deposit? _deposit; + private Customer? _customer; + + [BobcatSpec] + public async Task registering_a_user_creates_a_customer_profile() + { + await WhenAUserRegisters("pay.user@example.com", "Pay User"); + + ThenTheResponseStatusIs(200); + await ThenACustomerProfileExistsForThatUser(); + } + + [BobcatSpec] + public async Task registering_without_an_email_returns_400() + { + await WhenAUserRegisters("", "No Email"); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task registering_an_email_that_is_already_taken_returns_409() + { + await GivenARegisteredUser("dup.pay@example.com", "First Claimant"); + + await WhenAUserRegisters("dup.pay@example.com", "Second Claimant"); + + ThenTheResponseStatusIs(409); + } + + [BobcatSpec] + public async Task completing_a_customer_profile_creates_a_wallet() + { + await GivenARegisteredUser("complete.user@example.com", "John Doe"); + + await WhenTheCustomerProfileIsCompleted("Johnny", "Poland"); + + ThenTheResponseStatusIs(200); + ThenTheCustomerProfileIsMarkedComplete(); + await ThenTheCustomerHasWallets(1); + ThenTheWalletBalanceIs(0); + } + + [BobcatSpec] + public async Task completing_a_customer_that_does_not_exist_returns_404() + { + await WhenTheProfileOfAnUnknownCustomerIsCompleted(); + + ThenTheResponseStatusIs(404); + } + + [BobcatSpec] + public async Task adding_funds_raises_the_wallet_balance() + { + await GivenARegisteredUser("addfunds@example.com", "Funds User"); + await GivenACompletedCustomerProfile("Funds", "Poland"); + + await WhenFundsAreAdded(100); + + ThenTheResponseStatusIs(200); + ThenTheWalletBalanceIs(100); + } + + [BobcatSpec] + public async Task adding_a_negative_amount_returns_400() + { + await GivenARegisteredUser("bad.funds@example.com", "Bad Funds"); + await GivenACompletedCustomerProfile("Bad Funds", "Poland"); + + await WhenFundsAreAdded(-5); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task transferring_funds_moves_the_balance_between_wallets() + { + await GivenARegisteredUser("sender@example.com", "Sender"); + await GivenACompletedCustomerProfile("Sender", "Poland"); + await GivenFundsWereAdded(200); + await GivenASecondCustomer("receiver@example.com", "Receiver"); + + await WhenFundsAreTransferredToTheSecondCustomer(50); + + ThenTheResponseStatusIs(200); + ThenTheWalletBalanceIs(150); + await ThenTheSecondCustomersWalletBalanceIs(50); + } + + [BobcatSpec] + public async Task transferring_more_than_the_balance_returns_400() + { + await GivenARegisteredUser("broke@example.com", "Broke User"); + await GivenACompletedCustomerProfile("Broke User", "Poland"); + await GivenASecondCustomer("broke.receiver@example.com", "Broke Receiver"); + + await WhenFundsAreTransferredToTheSecondCustomer(1000); + + ThenTheResponseStatusIs(400); + } + + [BobcatSpec] + public async Task creating_a_deposit_records_it_as_completed() + { + await GivenARegisteredUser("depositor@example.com", "Depositor"); + await GivenACompletedCustomerProfile("Depositor", "Poland"); + + await WhenADepositIsCreated(50, "PLN"); + + ThenTheResponseStatusIs(200); + ThenTheDepositIsCompleted(); + ThenTheDepositCurrencyIs("PLN"); + } + + [BobcatSpec] + public async Task depositing_a_negative_amount_returns_400() + { + await GivenARegisteredUser("bad.deposit@example.com", "Bad Depositor"); + await GivenACompletedCustomerProfile("Bad Depositor", "Poland"); + + await WhenADepositIsCreated(-1, "PLN"); + + ThenTheResponseStatusIs(400); + } + + // ---- registration ----------------------------------------------------------------------- + + [Given("a user registered as \"{email}\" named \"{fullName}\"")] + public Task GivenARegisteredUser(string email, string fullName) => register(email, fullName); + + [When("a user registers as \"{email}\" named \"{fullName}\"")] + public Task WhenAUserRegisters(string email, string fullName) => register(email, fullName); + + private async Task register(string email, string fullName) + { + var result = await TrackedAsync(() => + PostJsonAsync("/api/users", new RegisterUser(email, fullName, Password))); + + _lastStatusCode = result.StatusCode; + + // The Customer is created by the Customers module in response to the cascaded UserCreated, and + // carries the same id as the User. + if (result.Body is not null && result.Body.Id != Guid.Empty && _customerId == Guid.Empty) + _customerId = result.Body.Id; + } + + // ---- customer profile ------------------------------------------------------------------- + + [Given("the customer profile was completed as \"{name}\" from \"{nationality}\"")] + public Task GivenACompletedCustomerProfile(string name, string nationality) => completeProfile(_customerId, name, nationality); + + [When("the customer profile is completed as \"{name}\" from \"{nationality}\"")] + public Task WhenTheCustomerProfileIsCompleted(string name, string nationality) => completeProfile(_customerId, name, nationality); + + [When("the profile of a customer that does not exist is completed")] + public Task WhenTheProfileOfAnUnknownCustomerIsCompleted() => completeProfile(Guid.NewGuid(), "Nobody", "Nowhere"); + + private async Task completeProfile(Guid customerId, string name, string nationality) + { + var result = await TrackedAsync(() => + PutJsonAsync( + $"/api/customers/{customerId}/complete", new CompleteCustomer(customerId, name, name, nationality))); + + _lastStatusCode = result.StatusCode; + _customer = result.Body; + + // Completing the profile is what makes the Wallets module create a wallet, so this is the first + // point at which the owner has one to look up. + if (result.StatusCode == 200) (_walletId, _wallet) = await loadWallet(customerId); + } + + [Given("a second customer \"{email}\" named \"{fullName}\"")] + public async Task GivenASecondCustomer(string email, string fullName) + { + var registered = await TrackedAsync(() => + PostJsonAsync("/api/users", new RegisterUser(email, fullName, Password))); + + _secondCustomerId = registered.Body!.Id; + + await TrackedAsync(() => + PutJsonAsync( + $"/api/customers/{_secondCustomerId}/complete", + new CompleteCustomer(_secondCustomerId, fullName, fullName, "Poland"))); + + (_secondWalletId, _) = await loadWallet(_secondCustomerId); + } + + // ---- wallets ---------------------------------------------------------------------------- + + [Given("{amount} was added to the wallet")] + public Task GivenFundsWereAdded(decimal amount) => addFunds(amount); + + [When("{amount} is added to the wallet")] + public Task WhenFundsAreAdded(decimal amount) => addFunds(amount); + + private async Task addFunds(decimal amount) + { + var result = await TrackedAsync(() => + PostJsonAsync($"/api/wallets/{_walletId}/funds/add", new AddFunds(_walletId, amount))); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _wallet = result.Body; + } + + [When("{amount} is transferred to the second customer")] + public async Task WhenFundsAreTransferredToTheSecondCustomer(decimal amount) + { + var result = await TrackedAsync(() => + PostJsonAsync( + "/api/wallets/transfer", new TransferFunds(_walletId, _secondWalletId, amount))); + + _lastStatusCode = result.StatusCode; + if (result.Body is not null) _wallet = result.Body; + } + + // ---- deposits --------------------------------------------------------------------------- + + [When("a deposit of {amount} {currency} is created")] + public async Task WhenADepositIsCreated(decimal amount, string currency) + { + var result = await TrackedAsync(() => + PostJsonAsync( + "/api/payments/deposits", new CreateDeposit(_customerId, currency, amount))); + + _lastStatusCode = result.StatusCode; + _deposit = result.Body; + } + + // ---- assertions ------------------------------------------------------------------------- + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + [Then("a customer profile exists for that user")] + public async Task ThenACustomerProfileExistsForThatUser() + { + var result = await GetJsonAsync($"/api/customers/{_customerId}"); + SpecAssert.Check("status", result.StatusCode, 200); + SpecAssert.Check("id", result.Body?.Id, _customerId); + } + + [Then("the customer profile is marked complete")] + public void ThenTheCustomerProfileIsMarkedComplete() + => SpecAssert.Check("IsCompleted", _customer?.IsCompleted, true); + + [Then("the customer has {count} wallet")] + public async Task ThenTheCustomerHasWallets(int count) + { + var result = await GetJsonAsync>($"/api/wallets/owner/{_customerId}"); + SpecAssert.Check("count", result.Body?.Count ?? 0, count); + } + + [Then("the wallet balance is {balance}")] + public void ThenTheWalletBalanceIs(decimal balance) => SpecAssert.Check("balance", _wallet?.Balance, balance); + + [Then("the second customer's wallet balance is {balance}")] + public async Task ThenTheSecondCustomersWalletBalanceIs(decimal balance) + => SpecAssert.Check("balance", (await GetJsonAsync($"/api/wallets/{_secondWalletId}")).Body?.Balance, balance); + + [Then("the deposit is completed")] + public void ThenTheDepositIsCompleted() => SpecAssert.Check("status", _deposit?.Status, DepositStatus.Completed); + + [Then("the deposit currency is {currency}")] + public void ThenTheDepositCurrencyIs(string currency) => SpecAssert.Check("currency", _deposit?.Currency, currency); + + private async Task<(Guid, Wallet?)> loadWallet(Guid ownerId) + { + var wallet = (await GetJsonAsync>($"/api/wallets/owner/{ownerId}")).Body?.FirstOrDefault(); + return (wallet?.Id ?? Guid.Empty, wallet); + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Tests/Tests.csproj b/src/Samples/Bobcat/PaymentsMonolith/Tests/Tests.csproj new file mode 100644 index 000000000..e2c968a26 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + PaymentsMonolith.Tests + PaymentsMonolith.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/PaymentsMonolith/Tests/Usings.cs b/src/Samples/Bobcat/PaymentsMonolith/Tests/Usings.cs new file mode 100644 index 000000000..c17dbd16f --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Tests/Usings.cs @@ -0,0 +1,3 @@ +global using Bobcat; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/PaymentsMonolith/Users/RegisterUser.cs b/src/Samples/Bobcat/PaymentsMonolith/Users/RegisterUser.cs new file mode 100644 index 000000000..fc92f7ca8 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Users/RegisterUser.cs @@ -0,0 +1,47 @@ +using FluentValidation; +using Marten; +using Microsoft.AspNetCore.Mvc; +using PaymentsMonolith; +using Wolverine.Http; + +namespace Users; + +public record RegisterUser(string Email, string FullName, string Password) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.FullName).NotEmpty(); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8); + } + } +} + +public static class RegisterUserEndpoint +{ + public static async Task ValidateAsync(RegisterUser command, IQuerySession session) + { + var exists = await session.Query().AnyAsync(u => u.Email == command.Email); + if (exists) + return new ProblemDetails { Detail = $"Email '{command.Email}' already registered", Status = 409 }; + + return WolverineContinue.NoProblems; + } + + [WolverinePost("/api/users")] + public static (User, UserCreated) Post(RegisterUser command, IDocumentSession session) + { + var user = new User + { + Id = Guid.NewGuid(), + Email = command.Email, + FullName = command.FullName, + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(user); + return (user, new UserCreated(user.Id, user.Email, user.FullName)); + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Users/User.cs b/src/Samples/Bobcat/PaymentsMonolith/Users/User.cs new file mode 100644 index 000000000..4cab39519 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Users/User.cs @@ -0,0 +1,11 @@ +namespace Users; + +public class User +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public string Role { get; set; } = "user"; + public bool IsActive { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Wallets/AddFunds.cs b/src/Samples/Bobcat/PaymentsMonolith/Wallets/AddFunds.cs new file mode 100644 index 000000000..e74869e80 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Wallets/AddFunds.cs @@ -0,0 +1,43 @@ +using FluentValidation; +using Marten; +using PaymentsMonolith; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Wallets; + +public record AddFunds(Guid WalletId, decimal Amount, string TransferName = "deposit", string? Metadata = null) +{ + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.WalletId).NotEmpty(); + RuleFor(x => x.Amount).GreaterThan(0); + } + } +} + +public static class AddFundsEndpoint +{ + [WolverinePost("/api/wallets/{walletId}/funds/add")] + public static (Wallet, FundsAdded) Post( + AddFunds command, + [Entity("WalletId", Required = true)] Wallet wallet, + IDocumentSession session) + { + wallet.Balance += command.Amount; + wallet.Transfers.Add(new Transfer + { + Name = command.TransferName, + Type = "Incoming", + Amount = command.Amount, + Metadata = command.Metadata, + CreatedAt = DateTimeOffset.UtcNow, + }); + + session.Store(wallet); + + return (wallet, new FundsAdded(wallet.Id, wallet.OwnerId, wallet.Currency, command.Amount)); + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Wallets/CustomerCompletedHandler.cs b/src/Samples/Bobcat/PaymentsMonolith/Wallets/CustomerCompletedHandler.cs new file mode 100644 index 000000000..3b33d3b4a --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Wallets/CustomerCompletedHandler.cs @@ -0,0 +1,28 @@ +using Marten; +using PaymentsMonolith; + +namespace Wallets; + +/// +/// Handles CustomerCompleted from the Customers module. +/// Creates a wallet for the newly completed customer. +/// Replaces: Contract + InboxEventHandlerDecorator + IEventHandler + IWalletRepository +/// +public static class CustomerCompletedHandler +{ + public static WalletCreated Handle(CustomerCompleted message, IDocumentSession session) + { + var wallet = new Wallet + { + Id = Guid.NewGuid(), + OwnerId = message.CustomerId, + OwnerName = message.FullName, + Currency = "PLN", + CreatedAt = DateTimeOffset.UtcNow, + }; + + session.Store(wallet); + + return new WalletCreated(wallet.Id, wallet.OwnerId, wallet.Currency); + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Wallets/GetWallets.cs b/src/Samples/Bobcat/PaymentsMonolith/Wallets/GetWallets.cs new file mode 100644 index 000000000..399192946 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Wallets/GetWallets.cs @@ -0,0 +1,25 @@ +using Marten; +using Wolverine.Http; +using Wolverine.Persistence; + +namespace Wallets; + +public static class GetWalletsEndpoint +{ + [WolverineGet("/api/wallets")] + public static Task> Get(IQuerySession session, CancellationToken ct) + => session.Query().ToListAsync(ct); +} + +public static class GetWalletByIdEndpoint +{ + [WolverineGet("/api/wallets/{id}")] + public static Wallet? Get(Guid id, [Entity] Wallet? wallet) => wallet; +} + +public static class GetWalletByOwnerEndpoint +{ + [WolverineGet("/api/wallets/owner/{ownerId}")] + public static Task> Get(Guid ownerId, IQuerySession session, CancellationToken ct) + => session.Query().Where(w => w.OwnerId == ownerId).ToListAsync(ct); +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Wallets/TransferFunds.cs b/src/Samples/Bobcat/PaymentsMonolith/Wallets/TransferFunds.cs new file mode 100644 index 000000000..ad83698f3 --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Wallets/TransferFunds.cs @@ -0,0 +1,54 @@ +using Marten; +using Microsoft.AspNetCore.Mvc; +using PaymentsMonolith; +using Wolverine.Http; + +namespace Wallets; + +public record TransferFunds(Guid FromWalletId, Guid ToWalletId, decimal Amount); + +public static class TransferFundsEndpoint +{ + public static async Task ValidateAsync(TransferFunds command, IQuerySession session) + { + var from = await session.LoadAsync(command.FromWalletId); + if (from is null) + return new ProblemDetails { Detail = "Source wallet not found", Status = 404 }; + if (from.Balance < command.Amount) + return new ProblemDetails { Detail = "Insufficient funds", Status = 400 }; + + var to = await session.LoadAsync(command.ToWalletId); + if (to is null) + return new ProblemDetails { Detail = "Destination wallet not found", Status = 404 }; + if (from.Currency != to.Currency) + return new ProblemDetails { Detail = "Currency mismatch", Status = 400 }; + + return WolverineContinue.NoProblems; + } + + [WolverinePost("/api/wallets/transfer")] + public static async Task<(Wallet, FundsDeducted, FundsAdded)> Post( + TransferFunds command, + IDocumentSession session, + CancellationToken ct) + { + var from = await session.LoadAsync(command.FromWalletId, ct); + var to = await session.LoadAsync(command.ToWalletId, ct); + + var transferId = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + + from!.Balance -= command.Amount; + from.Transfers.Add(new Transfer { Id = transferId, Name = "transfer-out", Type = "Outgoing", Amount = command.Amount, CreatedAt = now }); + + to!.Balance += command.Amount; + to.Transfers.Add(new Transfer { Id = transferId, Name = "transfer-in", Type = "Incoming", Amount = command.Amount, CreatedAt = now }); + + session.Store(from); + session.Store(to); + + return (from, + new FundsDeducted(from.Id, from.OwnerId, from.Currency, command.Amount), + new FundsAdded(to.Id, to.OwnerId, to.Currency, command.Amount)); + } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/Wallets/Wallet.cs b/src/Samples/Bobcat/PaymentsMonolith/Wallets/Wallet.cs new file mode 100644 index 000000000..4ab9fb50a --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/Wallets/Wallet.cs @@ -0,0 +1,22 @@ +namespace Wallets; + +public class Wallet +{ + public Guid Id { get; set; } + public Guid OwnerId { get; set; } + public string OwnerName { get; set; } = string.Empty; + public string Currency { get; set; } = "PLN"; + public decimal Balance { get; set; } + public List Transfers { get; set; } = []; + public DateTimeOffset CreatedAt { get; set; } +} + +public class Transfer +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; // Incoming, Outgoing + public decimal Amount { get; set; } + public string? Metadata { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/Samples/Bobcat/PaymentsMonolith/appsettings.json b/src/Samples/Bobcat/PaymentsMonolith/appsettings.json new file mode 100644 index 000000000..8cac05a4f --- /dev/null +++ b/src/Samples/Bobcat/PaymentsMonolith/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "Marten": "Host=localhost;Port=5433;Database=inflow;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Events.cs b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Events.cs new file mode 100644 index 000000000..9e560d5a8 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Events.cs @@ -0,0 +1,55 @@ +using JasperFx.Events; +using Wolverine.Http; + +namespace ProjectManagement.Api; + +public record ProjectCreated(string Name); + +public record CreateProject(string Name, string Admin, string[] TeamMembers); + +public record ProjectCreation : CreationResponse +{ + public ProjectCreation(string Url, Guid ProjectId) : base(Url, ProjectId) + { + } +} + +public record ProjectCompleted; +public record AdminAssigned(string UserName); +public record TeamMemberAssigned(string UserName); +public record TeamMemberUnAssigned(string UserName); + +public enum ProjectStatus +{ + Ready, + InProgress, + Completed +} + +// Narrow view of the project that's just good enough for some +// commands and searching +public record ProjectDetails(Guid Id, string Name, ProjectStatus Status) +{ + public ProjectDetails(IEvent created) : this(created.Id, created.Data.Name, ProjectStatus.Ready) + { + + } + + public ProjectDetails Apply(ProjectCompleted completed) + => this with { Status = ProjectStatus.Completed }; +} + +// Full view of the Project, but more expensive in the end to keep up +public class Project +{ + public Guid Id { get; set; } + + public ProjectStatus Status { get; set; } + + public string Name { get; set; } + + public string AdminName { get; set; } + + public List TeamMembers { get; set; } = new(); +} + diff --git a/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Program.cs b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Program.cs new file mode 100644 index 000000000..6556c95f6 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Program.cs @@ -0,0 +1,63 @@ +using Marten; +using JasperFx; +using ProjectManagement.Api; +using Wolverine; +using Wolverine.Http; +using Wolverine.Marten; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +builder.Services.AddMarten(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("marten"); + opts.Connection(connectionString); +}) +// More on this in a bit... +.IntegrateWithWolverine() + +// Minor optimization +.UseLightweightSessions(); + +builder.Services.AddWolverineHttp(); + +builder.Host.UseWolverine(opts => +{ + // more in a bit... +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.MapPost("/api/project/create", async (CreateProject command, IDocumentSession session) => +{ + var projectCreated = new ProjectCreated(command.Name); + var adminAssigned = new AdminAssigned(command.Name); + var events = new object[] { projectCreated, adminAssigned } + .Concat(command.TeamMembers.Select(x => new TeamMemberAssigned(x))).ToArray(); + + var id = session.Events.StartStream(events).Id; + + await session.SaveChangesAsync(); + + // New Stream Id for the project we just created + return Results.Ok(id); +}); + +app.MapWolverineEndpoints(); + +// Replaced the standard command line runner with the +// Critter Stack's expanded options +return await app.RunJasperFxCommands(args); + +// Just to make testing a little easier +public partial class Program {} + diff --git a/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/ProjectManagement.Api.csproj b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/ProjectManagement.Api.csproj new file mode 100644 index 000000000..3c3417b66 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/ProjectManagement.Api.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + diff --git a/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/ProjectManagement.Api.http b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/ProjectManagement.Api.http new file mode 100644 index 000000000..e2f7bd6e5 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/ProjectManagement.Api.http @@ -0,0 +1,6 @@ +@ProjectManagement.Api_HostAddress = http://localhost:5139 + +GET {{ProjectManagement.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Properties/launchSettings.json b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Properties/launchSettings.json new file mode 100644 index 000000000..9eb15c500 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5139", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7168;http://localhost:5139", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/appsettings.Development.json b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/appsettings.json b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/appsettings.json new file mode 100644 index 000000000..ee365290c --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "marten": "Host=localhost;Port=5433;Database=postgres;Username=postgres;password=postgres" + } +} diff --git a/src/Samples/Bobcat/ProjectManagement/ProjectManagement.slnx b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.slnx new file mode 100644 index 000000000..68e9bc11e --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/ProjectManagement.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/Samples/Bobcat/ProjectManagement/Tests/AssemblyAttributes.cs b/src/Samples/Bobcat/ProjectManagement/Tests/AssemblyAttributes.cs new file mode 100644 index 000000000..4c0e68164 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/Tests/AssemblyAttributes.cs @@ -0,0 +1,18 @@ +using ProjectManagement.Tests; +using Microsoft.AspNetCore.Mvc.Testing; + +// One host for the whole run, booted once. +[assembly: AssemblyFixture(typeof(ProjectManagementHost))] + +// Every spec empties the store before it runs (see ProjectManagementSpec), so two specs running at once would +// wipe each other's data. +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + +// Tell ASP.NET Core's WebApplicationFactory (used by Alba) where to find the host project's content +// root. The path is relative to the test assembly's bin directory at run time: from +// `Tests/bin//net10.0/`, four levels up is the sample's folder. +[assembly: WebApplicationFactoryContentRoot( + "ProjectManagement.Api", + "../../../../ProjectManagement.Api", + "appsettings.json", + "1")] diff --git a/src/Samples/Bobcat/ProjectManagement/Tests/ProjectManagementHost.cs b/src/Samples/Bobcat/ProjectManagement/Tests/ProjectManagementHost.cs new file mode 100644 index 000000000..abeb2e053 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/Tests/ProjectManagementHost.cs @@ -0,0 +1,20 @@ +using Alba; + +namespace ProjectManagement.Tests; + +/// +/// The application under test, booted once for the run through Alba. The connection string comes +/// from the host's own appsettings.json, or from ConnectionStrings__Marten in the environment, +/// which is how ./build.sh Samples points it at its own database. +/// +public class ProjectManagementHost : IAsyncLifetime +{ + public IAlbaHost Host { get; private set; } = null!; + + public async ValueTask InitializeAsync() => Host = await AlbaHost.For(); + + public async ValueTask DisposeAsync() + { + if (Host is not null) await Host.DisposeAsync(); + } +} diff --git a/src/Samples/Bobcat/ProjectManagement/Tests/ProjectManagementSpec.cs b/src/Samples/Bobcat/ProjectManagement/Tests/ProjectManagementSpec.cs new file mode 100644 index 000000000..6c0f49e2c --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/Tests/ProjectManagementSpec.cs @@ -0,0 +1,60 @@ +using Alba; + +namespace ProjectManagement.Tests; + +/// One HTTP call as a step sees it: the status, and the body if there was one. +public record HttpResult(int StatusCode, T? Body); + +/// +/// The base for every ProjectManagement spec: the HTTP calls a step makes, over Alba, and an empty store before +/// every spec. +/// +/// +/// +/// It is Bobcat's store-only as well, so a spec can assert what an act +/// appended to the event store and what the aggregate became. A When step goes through +/// , which takes the store's high-water mark, runs the request, +/// and reads back everything appended since -- so ThenEvents and ThenNoEvents judge exactly +/// what that request appended, cascades included. +/// +/// +/// The HTTP status is surfaced rather than asserted. Alba checks for a 200 unless told otherwise, and a +/// spec asserts whatever status it expects, so a 404 a spec is testing for must not fail the call that +/// produced it. +/// +/// +public abstract class ProjectManagementSpec(ProjectManagementHost app) : CritterStackSpec(app.Host), IAsyncLifetime +{ + protected IAlbaHost Alba => app.Host; + + // Every spec mints its own stream, so the reset is housekeeping: it keeps the event store from growing without bound across runs. + public async ValueTask InitializeAsync() => await app.Host.ResetEventStoresAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + protected Task> PostJsonAsync(string url, TRequest body) + => SendAsync(s => s.Post.Json(body!).ToUrl(url)); + + protected Task> PutJsonAsync(string url, TRequest body) + => SendAsync(s => s.Put.Json(body!).ToUrl(url)); + + protected Task> GetJsonAsync(string url) + => SendAsync(s => s.Get.Url(url)); + + protected Task> DeleteAsync(string url) + => SendAsync(s => s.Delete.Url(url)); + + protected async Task> SendAsync(Action configure) + { + var result = await Alba.Scenario(s => + { + configure(s); + s.IgnoreStatusCode(); + }); + + T? body = default; + try { body = await result.ReadAsJsonAsync(); } catch { /* not every response carries JSON */ } + + return new HttpResult(result.Context.Response.StatusCode, body); + } +} diff --git a/src/Samples/Bobcat/ProjectManagement/Tests/ProjectSpecs.cs b/src/Samples/Bobcat/ProjectManagement/Tests/ProjectSpecs.cs new file mode 100644 index 000000000..e734955ae --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/Tests/ProjectSpecs.cs @@ -0,0 +1,46 @@ +using ProjectManagement.Api; + +namespace ProjectManagement.Tests; + +/// +/// Creating a project starts an event stream: the project, its admin, and one event per team member. +/// The endpoint is a plain Minimal API MapPost, so the When step goes through +/// and the spec asserts what the request appended. +/// +[BobcatFeature("Project Management")] +public class ProjectSpecs(ProjectManagementHost app) : ProjectManagementSpec(app) +{ + private Guid _projectId; + private int _lastStatusCode; + + [BobcatSpec] + public async Task create_a_project() + { + await WhenAProjectIsCreated("My Project", "ada", "alice", "bob"); + + ThenTheResponseStatusIs(200); + ThenTheProjectIdIsReturned(); + ThenEvents(typeof(ProjectCreated), typeof(AdminAssigned), typeof(TeamMemberAssigned), typeof(TeamMemberAssigned)); + ThenEmitted(new ProjectCreated("My Project"), new TeamMemberAssigned("alice"), new TeamMemberAssigned("bob")); + await ThenStreamIsStarted(_projectId); + } + + public Task WhenAProjectIsCreated(string name, string admin, params string[] teamMembers) + => WhenActing($"a project named \"{name}\" is created with admin {admin} and team {string.Join(", ", teamMembers)}", + async _ => + { + var result = await PostJsonAsync( + "/api/project/create", new CreateProject(name, admin, teamMembers)); + + _lastStatusCode = result.StatusCode; + _projectId = result.Body; + }); + + [Then("the response status is {status}")] + public void ThenTheResponseStatusIs(int status) => SpecAssert.Check("status", _lastStatusCode, status); + + /// The id the endpoint answers with is the id of the stream it started, not merely some Guid. + [Then("the project id is returned")] + public void ThenTheProjectIdIsReturned() + => SpecAssert.Check("project id", _projectId, TheStartedStream()); +} diff --git a/src/Samples/Bobcat/ProjectManagement/Tests/Tests.csproj b/src/Samples/Bobcat/ProjectManagement/Tests/Tests.csproj new file mode 100644 index 000000000..d0c1d4fe2 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + enable + enable + true + + ProjectManagement.Tests + ProjectManagement.Tests + + + + + + + + + + + + diff --git a/src/Samples/Bobcat/ProjectManagement/Tests/Usings.cs b/src/Samples/Bobcat/ProjectManagement/Tests/Usings.cs new file mode 100644 index 000000000..6602f579b --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/Tests/Usings.cs @@ -0,0 +1,4 @@ +global using Bobcat; +global using Bobcat.CritterStack; +global using Bobcat.Xunit; +global using Xunit; diff --git a/src/Samples/Bobcat/ProjectManagement/docker-compose.yml b/src/Samples/Bobcat/ProjectManagement/docker-compose.yml new file mode 100644 index 000000000..2385afce3 --- /dev/null +++ b/src/Samples/Bobcat/ProjectManagement/docker-compose.yml @@ -0,0 +1,43 @@ +version: '3' + +networks: + app-tier: + driver: bridge + +services: + postgresql: + image: "postgres:latest" + ports: + - "5433:5432" + environment: + - POSTGRES_DATABASE=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + + rabbitmq: + image: "rabbitmq:3" + ports: + - "5672:5672" + - "15672:15672" + + zookeeper: + image: confluentinc/cp-zookeeper:latest + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - 22181:2181 + + kafka: + image: confluentinc/cp-kafka:latest + depends_on: + - zookeeper + ports: + - 29092:29092 + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 \ No newline at end of file diff --git a/src/Samples/Bobcat/README.md b/src/Samples/Bobcat/README.md new file mode 100644 index 000000000..c232da576 --- /dev/null +++ b/src/Samples/Bobcat/README.md @@ -0,0 +1,83 @@ +# CritterStackSamples + +Sample projects using the "Critter Stack" tools ([Marten](https://martendb.io) and [Wolverine](https://wolverinefx.net)) and related [JasperFx](https://github.com/jasperfx) projects. + +These samples moved here from the Bobcat repository with the Critter Stack vocabulary they are written +against (GH-5082, JasperFx/bobcat#744). They reference this repository's Wolverine projects, and WolverineFx.Bobcat +with the vocabulary, by `ProjectReference`, and Bobcat itself as packages. + +Most of them require PostgreSQL. From the repository root: + +```bash +docker compose up -d postgresql +``` + +That publishes Postgres on **5433**, the port every sample's default connection string already names. Each sample +uses its own database on that server; `./build.sh BobcatSamples` creates them, or `init/create-databases.sql` does +when you use the `docker-compose.yml` in this folder instead (also on 5433, so run one or the other, not both). To +use a PostgreSQL you already run, point the sample's `appsettings.json` at it instead. + +`./build.sh BobcatSamples` (build/BobcatSamples.cs) builds every sample, runs every runnable sample's specs, and runs +BankAccountES a second time on Fisher (SQLite). It is what the `bobcat-samples` workflow runs. + +## Running a sample's specs + +Every sample with specs carries a `Tests/` subdirectory of Bobcat's projected (code-first) specs: +ordinary xUnit v3 tests that render as Given/When/Then specifications. There are no `.feature` files. + +```bash +cd PaymentsMonolith +dotnet test --project Tests # run them +BOBCAT_SPEC_CONSOLE=1 dotnet run --project Tests # ...and print every spec as it rendered +``` + +Each `Tests/` project boots the host once through Alba (`AlbaHost.For()`, an xUnit assembly +fixture), and a base spec class in the same folder holds the HTTP calls and empties the store before +every spec. The event-sourced samples -- BookingMonolith, MeetingGroupMonolith, ProjectManagement and +BankAccountES -- derive that base from `CritterStackSpec`, so a spec can also assert the events a +request appended and the aggregate they built. + +`Directory.Build.props` gives every project named `Tests` the Bobcat, Bobcat.Xunit and Bobcat.Generators +packages and a reference to WolverineFx.Bobcat, so no `Tests.csproj` states them. Because Bobcat comes in as packages, +their `buildTransitive` props apply as they do for any consumer: the `InterceptorsNamespaces` opt-in for +`Bobcat.Generated` (without it the build stops with BOBCAT037) and the `Bobcat.Xunit` `TestingPlatformBuilderHook`. + +## Samples + +| Sample | Original Project | Description | Patterns | +|--------|-----------------|-------------|----------| +| [CqrsMinimalApi](CqrsMinimalApi/) | [matjazbravc/CQRS.MinimalAPI.Demo](https://github.com/matjazbravc/CQRS.MinimalAPI.Demo) | Student CRUD — simplest MediatR → Wolverine port | Wolverine.HTTP, Marten documents, `[Entity]`, Alba tests | +| [CleanArchitectureTodos](CleanArchitectureTodos/) | [jasontaylordev/CleanArchitecture](https://github.com/jasontaylordev/CleanArchitecture) | Todo lists — Clean Architecture unraveling (67 files → 11) | FluentValidation middleware, `ValidateAsync`, one-file-per-request layout | +| [OutboxDemo](OutboxDemo/) | [MassTransit/Sample-Outbox](https://github.com/MassTransit/Sample-Outbox) | Registration workflow with transactional outbox and Saga | Marten outbox, Wolverine Saga, cascading messages, `Results.NoContent()` | +| [EcommerceMicroservices](EcommerceMicroservices/) | [aspnetrun/run-aspnetcore-microservices](https://github.com/aspnetrun/run-aspnetcore-microservices) | E-commerce with 4 services communicating via RabbitMQ | Wolverine RabbitMQ transport, per-service databases, `[Entity]` | +| [EcommerceModularMonolith](EcommerceModularMonolith/) | Same as above | Same domain collapsed into one app with durable local queues | Schema-per-module, durable local queues, same handler code as microservices | +| [MeetingGroupMonolith](MeetingGroupMonolith/) | [kgrzybek/modular-monolith-with-ddd](https://github.com/kgrzybek/modular-monolith-with-ddd) | Meeting group scheduling — 5 modules with event sourcing | Marten event store (Payments), durable local queues, inter-module events | +| [PaymentsMonolith](PaymentsMonolith/) | [devmentors/Inflow](https://github.com/devmentors/Inflow) | Virtual payments — 4 modules (Users, Customers, Wallets, Payments) | Schema-per-module, cascading events across modules, `ValidateAsync` | +| [BookingMonolith](BookingMonolith/) | [meysamhadeli/booking-modular-monolith](https://github.com/meysamhadeli/booking-modular-monolith) | Travel booking — replaces EventStoreDB + MongoDB with Marten | Marten event store, inline snapshots, multiple `[Entity]` batch loading | +| [BankAccountES](BankAccountES/) | Inspired by [andreschaffer/event-sourcing-cqrs-examples](https://github.com/andreschaffer/event-sourcing-cqrs-examples) | Bank accounts — store-agnostic event sourcing; runs on Marten (Postgres) or Fisher (SQLite) from one set of handlers | `[DeciderFunction]`, `[Entity]`, `Storage.StartStream`, `IEventStoreOperations` / `IDocumentReadOperations`, inline `Snapshot` read model, `Validate` against aggregate state | +| [MoreSpeakers](MoreSpeakers/) | [cwoodruff/morespeakers-com](https://github.com/cwoodruff/morespeakers-com) | Speaker mentorship platform — Marten as document DB | Nested collections, multiple `[Entity]` batch queries, mentorship lifecycle | + +## Common Patterns Across Samples + +- **`IntegrateWithWolverine()` + `AutoApplyTransactions()`** — canonical Marten + Wolverine setup in every sample +- **`AddWolverineHttp()`** — required for Wolverine.HTTP endpoints +- **`[Entity]`** — declarative entity loading (Marten documents, event-sourced snapshots) +- **`[WriteAggregate]` + `IEventStream`** — event-sourced aggregate mutations +- **`ValidateAsync` / `Validate`** — sad-path validation separated from happy-path handlers +- **`Results.NoContent()`** — preferred over `[EmptyResponse]` for 204 responses with cascading messages +- **FluentValidation** — `UseFluentValidationProblemDetailMiddleware()` in `MapWolverineEndpoints()` +- **Alba + Bobcat specs** -- xUnit v3 specs over `AlbaHost`, with `ResetEventStoresAsync()` for test isolation + +## Running + +Each sample has its own `.slnx` file and `Tests/` subfolder. Requires PostgreSQL (see +`docker-compose.yml` in this folder), except `BankAccountES` on Fisher, which needs nothing: + +```bash +cd BankAccountES +dotnet test --project Tests # Marten, Postgres on 5433 +EventStore=Fisher dotnet test --project Tests # Fisher, a SQLite file, no docker +``` + +Every sample's specs are projected (code-first) xUnit v3 tests -- BankAccountES's +[README](BankAccountES/README.md#specifications) is the fullest worked example, slices and all. diff --git a/src/Samples/Bobcat/docker-compose.yml b/src/Samples/Bobcat/docker-compose.yml new file mode 100644 index 000000000..2f299cfab --- /dev/null +++ b/src/Samples/Bobcat/docker-compose.yml @@ -0,0 +1,28 @@ +# Postgres for the sample applications. +# +# cd samples && docker compose up -d +# +# Published on 5433, which is the port every sample's default connection string already names — +# see any samples/*/appsettings.json. Nothing in this repo used to provide it, so "run the +# sample" quietly depended on a Postgres the author happened to have running on that port. That +# is the gap that let nine samples rot: issue #70's whole point is that a sample is not fixed +# until it has been RUN, and there was nothing to run it against. +# +# Each sample uses its own database on the one server. Marten creates schemas and tables on +# demand, but not databases, so init/create-databases.sql creates them up front. +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5433:5432" + volumes: + - ./init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 diff --git a/src/Samples/Bobcat/init/create-databases.sql b/src/Samples/Bobcat/init/create-databases.sql new file mode 100644 index 000000000..36ddb6b2f --- /dev/null +++ b/src/Samples/Bobcat/init/create-databases.sql @@ -0,0 +1,12 @@ +-- One database per sample, on the shared server published by ../docker-compose.yml. +-- Marten creates schemas and tables on demand; it does not create databases. +-- Names must match the connection strings in each samples//appsettings.json. +CREATE DATABASE bank_account; +CREATE DATABASE booking; +CREATE DATABASE clean_architecture_todos; +CREATE DATABASE cqrs_minimal_api; +CREATE DATABASE ecommerce; +CREATE DATABASE inflow; +CREATE DATABASE meeting_groups; +CREATE DATABASE more_speakers; +CREATE DATABASE outbox_demo; diff --git a/src/Samples/Bobcat/sample-projects.md b/src/Samples/Bobcat/sample-projects.md new file mode 100644 index 000000000..1140d77a4 --- /dev/null +++ b/src/Samples/Bobcat/sample-projects.md @@ -0,0 +1,410 @@ +# Critter Stack Migration — Sample Project Candidates + +A curated inventory of open-source sample applications that are candidates for: + +- **Porting to Marten and/or Wolverine** (event sourcing, modular monolith, DDD) +- **Migration source targets** (MassTransit → Wolverine, MediatR → Wolverine, Clean Architecture unraveling) + +--- + +## Table of Contents + +1. [Event Sourcing & CQRS Samples](#1-event-sourcing--cqrs-samples) +2. [Modular Monolith Samples](#2-modular-monolith-samples) +3. [MassTransit Migration Targets](#3-masstransit-migration-targets) +4. [MediatR / Clean Architecture Migration Targets](#4-mediatr--clean-architecture-migration-targets) +5. [ASP.NET Core Minimal API Samples](#5-aspnet-core-minimal-api-samples) +6. [License Summary](#6-license-summary) + +--- + +## 1. Event Sourcing & CQRS Samples + +### `oskardudycz/EventSourcing.NetCore` +**GitHub:** https://github.com/oskardudycz/EventSourcing.NetCore +**License:** MIT +**Priority:** ⭐ Highest + +The most directly relevant event sourcing sample in the .NET ecosystem. Already uses Marten helpers (`WriteToAggregate`, `AggregateStream`) in several of its samples. Oskar Dudycz is deeply embedded in the Marten community. + +**Domains covered:** Carts, Orders, Payments, Shipments, Sagas +**Notable features:** +- Multiple sample styles: pure functions (no aggregates), classic aggregate, mixed ES + non-ES services +- EventStoreDB variants provide good "non-Marten" porting targets within the same repo +- Saga pattern with distributed processing and async command scheduling +- Self-paced workshop kit included +- Read models stored as Postgres tables using EF Core + +**Porting angle:** Port the EventStoreDB-backed samples to Marten; use Wolverine to replace the custom dispatch infrastructure. + +--- + +### `jasontaylordev/NorthwindTraders` +**GitHub:** https://github.com/jasontaylordev/NorthwindTraders +**License:** MIT +**Priority:** ⭐ High + +Jason Taylor's original Clean Architecture sample on the iconic Northwind Traders database. More of a real application than the later template — covers customers, products, orders, and employees. Universally known domain makes a port immediately legible to any .NET developer. + +**Stack:** ASP.NET Core, EF Core, MediatR, Clean Architecture +**Porting angle:** Classic MediatR + Clean Architecture unraveling; add Marten event sourcing to the Orders domain. + +--- + +### `andreschaffer/event-sourcing-cqrs-examples` +**GitHub:** https://github.com/andreschaffer/event-sourcing-cqrs-examples +**License:** MIT +**Priority:** Medium + +A minimalistic bank context with clients, accounts, deposits, and withdrawals. Pragmatic CQRS where writes use aggregates and reads exist on both sides of the model. + +**Domain:** Bank accounts, money transfers, transaction history +**Notable features:** Commutative read model (handles out-of-order events), DDD + REST demonstrated together +**Porting angle:** Clean, small domain — good starting point before tackling larger samples. Java-based, but domain is directly portable. + +--- + +### `kbastani/event-sourcing-microservices-example` +**GitHub:** https://github.com/kbastani/event-sourcing-microservices-example +**License:** Apache 2.0 +**Priority:** Low (Java/Spring) + +Social network domain demonstrating CQRS + event sourcing with Kafka. Useful primarily as a domain reference — the friend relationship graph provides a high-complexity test for projection modeling. + +--- + +## 2. Modular Monolith Samples + +### `kgrzybek/modular-monolith-with-ddd` +**GitHub:** https://github.com/kgrzybek/modular-monolith-with-ddd +**License:** MIT +**Priority:** ⭐ Highest + +One of the most-starred and most-cited modular monolith references in the .NET community. Production-quality code covering DDD tactical patterns, integration events, unit tests, integration tests, and an event store view. + +**Domain:** Meeting group / scheduling (Meetup.com-like) — Administration, Meetings, Payments, UserAccess modules +**Stack:** SQL Server, custom infrastructure (no MediatR/MassTransit — custom dispatcher) +**Notable features:** +- Each module is fully encapsulated with its own schema +- Event sourcing present in one module (demonstrable before/after) +- Database change management via DbUp migrations +- CI/CD pipeline included + +**Porting angle:** Replace custom infrastructure with Marten (event store) + Wolverine (messaging/dispatch). The custom outbox and inbox patterns map directly to Wolverine's durable messaging. + +--- + +### `devmentors/Inflow` +**GitHub:** https://github.com/devmentors/Inflow +**License:** MIT +**Priority:** ⭐ High + +Virtual payments app built as a modular monolith in .NET 6. Each module is an independent vertical slice with its own architecture, integrated primarily via events. No shared projects between modules — local contracts approach. + +**Domain:** Virtual payments — Users, Wallets, Transfers, Reports modules +**Stack:** PostgreSQL, RabbitMQ (in-memory for tests), custom modular framework +**Notable features:** +- Zero inter-module project references — pure event-driven integration +- Branch showing module-to-microservice migration path +- Companion `Inflow-micro` repo for full microservices version + +**Porting angle:** Replace RabbitMQ + custom event bus with Wolverine's transport abstraction. Replace custom persistence with Marten for event-sourced modules (Wallets, Transfers are natural ES candidates). + +--- + +### `meysamhadeli/booking-modular-monolith` +**GitHub:** https://github.com/meysamhadeli/booking-modular-monolith +**License:** MIT +**Priority:** ⭐ High + +Practical Modular Monolith on .NET 10 using Vertical Slice Architecture, Event Driven Architecture, CQRS, DDD, MassTransit, and Aspire. Uses an Event Store for the write side of the Booking module. Implements Inbox/Outbox for exactly-once delivery. + +**Domain:** Travel/booking — Flight, Passenger, Booking, Identity modules +**Stack:** MassTransit, MediatR, FluentValidation, Scalar/Swagger, OpenTelemetry, gRPC +**Notable features:** +- Event sourcing already in place on the Booking module +- Inbox/Outbox implemented (direct Wolverine comparison) +- End-to-end tests with Testcontainers +- Husky + conventional commits enforced + +**Porting angle:** Replace MassTransit → Wolverine, MediatR → Wolverine. Marten replaces the custom event store. This is one of the richest ports available. + +--- + +### `kamilbaczek/Modular-monolith-by-example` +**GitHub:** https://github.com/kamilbaczek/Modular-monolith-by-example +**License:** MIT +**Priority:** Medium + +Estimation tool for IT companies demonstrating modular monolith with DDD in .NET. Focuses on showcasing scalable, maintainable design with DDD principles. + +**Domain:** IT project estimation workflows +**Stack:** .NET, DDD patterns, modular architecture + +--- + +### `NeVeSpl/TestMe` +**GitHub:** https://github.com/NeVeSpl/TestMe +**License:** MIT (inferred from public repo) +**Priority:** Medium + +A quiz/test creation application using ASP.NET Core + PostgreSQL, vertical slices, outbox pattern for integration events, and an inbox for deduplication. Real-world quality with metrics endpoint. + +**Stack:** ASP.NET Core, EF Core, PostgreSQL, RabbitMQ or in-memory bus +**Notable features:** Optimistic concurrency, outbox/inbox patterns, CSS security policy, Storybook frontend + +--- + +### `devmentors/NPay` +**GitHub:** https://github.com/devmentors/NPay +**License:** MIT +**Priority:** Low-Medium + +Mini-course companion for modular monolith fundamentals. Smaller scope than Inflow — good as a first porting exercise before Inflow. + +--- + +## 3. MassTransit Migration Targets + +> **Licensing Note:** MassTransit v9 has moved to a commercial license. v8 remains Apache 2.0 through at least end of 2026. All samples below use v8 or earlier and are safe for open-source use. + +### `aspnetrun/run-aspnetcore-microservices` +**GitHub:** https://github.com/aspnetrun/run-aspnetcore-microservices +**License:** MIT +**Priority:** ⭐ Highest — Already uses Marten + +E-commerce microservices application using ASP.NET Core Minimal APIs, .NET 8, MassTransit (RabbitMQ), MediatR, **and Marten** (for the Ordering service). This is the single most compelling migration target: Wolverine would replace both MassTransit *and* MediatR, and Marten is already partially in use. + +**Domain:** E-commerce — Catalog, Basket, Discount, Ordering services +**Stack:** Minimal API, Vertical Slice Architecture, MassTransit, MediatR, Marten, PostgreSQL, Redis, MongoDB, Yarp API Gateway +**Notable features:** +- Marten already wired into one service +- BasketCheckout event flows from Basket → Ordering via MassTransit +- CQRS via MediatR with FluentValidation + Mapster +- Yarp reverse proxy with rate limiting + +--- + +### `MassTransit/Sample-ShoppingWeb` +**GitHub:** https://github.com/MassTransit/Sample-ShoppingWeb +**License:** Apache 2.0 +**Priority:** ⭐ High + +ASP.NET web application with a simulated shopping cart using a MassTransit state machine saga to track cart expiration via Quartz. + +**Domain:** Shopping cart with expiration / abandoned cart workflow +**Porting angle:** Shopping cart saga maps cleanly to Wolverine's durable saga model. Small enough to be a clean teaching example. + +--- + +### `MassTransit/Sample-Outbox` +**GitHub:** https://github.com/MassTransit/Sample-Outbox +**License:** Apache 2.0 +**Priority:** ⭐ High + +Demonstrates MassTransit's transactional outbox pattern. + +**Porting angle:** Since Wolverine's outbox is one of its flagship features (built into Marten's event store integration), this makes for a tight, focused before/after comparison. Ideal as an isolated demonstration. + +--- + +### `MassTransit/Sample-Batch` +**GitHub:** https://github.com/MassTransit/Sample-Batch +**License:** Apache 2.0 +**Priority:** Medium + +ASP.NET Core Web API demonstrating batch job processing via a MassTransit saga, with Swagger UI for triggering batches. Supports RabbitMQ or Azure Service Bus via configuration. + +**Notable features:** Routing slips, activity compensation, configurable transport +**Porting angle:** Covers advanced saga mechanics (routing slips, compensation) that Wolverine handles differently — good coverage of edge cases. + +--- + +### `ebubekirdinc/SuuCat` +**GitHub:** https://github.com/ebubekirdinc/SuuCat +**License:** MIT +**Priority:** Medium + +Microservices design patterns sample including Saga Orchestration via MassTransit state machine with RabbitMQ. OrderStateMachine coordinates Order → Stock reservation → Payment flow with rollback. + +**Domain:** Order processing with stock and payment saga +**Porting angle:** Good real-world saga orchestration example; the state machine maps directly to Wolverine's saga abstraction. + +--- + +## 4. MediatR / Clean Architecture Migration Targets + +> **Licensing Note:** MediatR is also moving toward commercialization (announced April 2025). There is an active community discussion in the `ardalis/CleanArchitecture` repo naming Wolverine as the leading open-source alternative. + +### `jasontaylordev/CleanArchitecture` +**GitHub:** https://github.com/jasontaylordev/CleanArchitecture +**License:** MIT +**Priority:** ⭐ Highest + +The most-starred Clean Architecture template in the .NET ecosystem (~16k stars). Uses MediatR throughout with pipeline behaviors for validation, logging, and performance. The canonical reference point for the pattern most teams are running today. + +**Domain:** Todo list (simple, intentionally generic) +**Stack:** ASP.NET Core, EF Core, MediatR, FluentValidation, AutoMapper, Angular/React frontend +**Notable features:** +- `dotnet new ca-sln` template with Angular, React, or API-only options +- Supports PostgreSQL, SQLite, SQL Server +- Full CI/CD pipeline to Azure included +- Comprehensive test suite + +**Unraveling story:** A typical use case has 5 artifacts (command, validator, handler, 2x pipeline behavior registrations). Wolverine collapses this to 1 handler class with inline `Validate()`. The diff is dramatic and highly bloggable. + +--- + +### `ardalis/CleanArchitecture` +**GitHub:** https://github.com/ardalis/CleanArchitecture +**License:** MIT +**Priority:** ⭐ High + +Steve Smith's Clean Architecture template (~16k stars). Uses a dedicated UseCases project organized by feature, with FastEndpoints for the Web layer rather than controllers. More opinionated than the Jason Taylor template, and has a companion **sample application** with richer domain (multiple aggregates, more DDD patterns) beyond the bare template. + +**Stack:** ASP.NET Core, FastEndpoints, MediatR, EF Core, Ardalis.Result, Ardalis.GuardClauses +**Notable features:** +- Commands use Repository pattern; Queries use direct data access (no repository required) +- FastEndpoints already thins the endpoint → mediator chain +- Active discussion thread on MediatR going commercial with Wolverine named as alternative + +**Unraveling story:** Ardalis explicitly calls out double-validation (FluentValidation in FastEndpoints *and* in MediatR pipeline) as a known tradeoff. Wolverine's middleware eliminates this duplication entirely — a concrete before/after with a respected community voice already acknowledging the friction. + +--- + +### `kgrzybek/sample-dotnet-core-cqrs-api` +**GitHub:** https://github.com/kgrzybek/sample-dotnet-core-cqrs-api +**License:** MIT +**Priority:** High + +A REST API using MediatR for command/query/domain event handling, DDD on the write side with EF Core, and raw SQL via Dapper on the read side. Implements Cache-Aside pattern. + +**Same author as:** `modular-monolith-with-ddd` +**Porting angle:** Smaller scope than the full modular monolith — a good first porting exercise by the same author, before tackling the larger project. Clean, well-structured code. + +--- + +### `gothinkster/aspnetcore-realworld-example-app` +**GitHub:** https://github.com/gothinkster/aspnetcore-realworld-example-app +**License:** MIT +**Priority:** High + +ASP.NET Core implementation of the RealWorld spec ("Conduit" blog platform). CQRS and MediatR with feature folders, vertical slices, FluentValidation, AutoMapper, EF Core on SQLite, and JWT auth. + +**Domain:** Blog platform — articles, comments, profiles, favorites, tags +**Notable features:** +- Implements the RealWorld API spec — plug in any frontend (React, Vue, Angular, etc.) +- Complete UI available via the RealWorld frontend ecosystem +- Open to porting to other ORMs/DBs (explicitly stated in readme) + +**Porting angle:** Well-defined API contract via the RealWorld spec provides a test harness for verifying the port. Any frontend continues to work unchanged. + +--- + +### `mehdihadeli/vertical-slice-api-template` +**GitHub:** https://github.com/mehdihadeli/vertical-slice-api-template +**License:** MIT +**Priority:** Medium-High + +Vertical Slice Architecture template on .NET 9. MediatR pipeline behaviors for FluentValidation, OpenTelemetry collector (Jaeger, Loki, Prometheus), PostgreSQL/EF Core, and multi-level tests. + +**Porting angle:** Vertical slices already map naturally to Wolverine's handler model. Good for demonstrating that Wolverine doesn't just replace MediatR but also simplifies the pipeline behavior ceremony. + +--- + +### `EduardoPires/EquinoxProject` +**GitHub:** https://github.com/EduardoPires/EquinoxProject +**License:** MIT +**Priority:** Medium + +Full ASP.NET Core application with DDD, CQRS, and Event Sourcing using EventStoreDB (or in-memory). Well-known in the Brazilian .NET community. + +**Porting angle:** EventStoreDB → Marten for event storage; MediatR → Wolverine for dispatch. + +--- + +## 5. ASP.NET Core Minimal API Samples + +### `matjazbravc/CQRS.MinimalAPI.Demo` +**GitHub:** https://github.com/matjazbravc/CQRS.MinimalAPI.Demo +**License:** MIT (inferred) +**Priority:** Medium + +Focused demonstration of CQRS with Minimal API, MediatR, EF Core 7, and SQLite. Explicitly illustrates the Minimal API → MediatR → handler wiring pattern. + +**Porting angle:** Clean before/after showing how Wolverine integrates with Minimal API endpoints without the mediator indirection. + +--- + +### `isaacOjeda/MinimalApiArchitecture` +**GitHub:** https://github.com/isaacOjeda/MinimalApiArchitecture +**License:** MIT +**Priority:** Medium + +Vertical Slice Architecture with CQRS via MediatR, FluentValidation, AutoMapper, EF Core, NSwag code generation, Serilog, Angular and Blazor frontends. + +**Porting angle:** Full-stack Minimal API vertical slice — Wolverine handler replaces MediatR handler with no change to the endpoint or frontend. + +--- + +### `CharlieDigital/dn8-modular-monolith` +**GitHub:** https://github.com/CharlieDigital/dn8-modular-monolith +**License:** MIT +**Priority:** Low-Medium + +Simple project management application on .NET 8 showing modular monolith structure with clean module boundaries, DI, and in-process communication. Three entities: Project, WorkItem, User. + +**Porting angle:** Very approachable starting point — good for introducing Wolverine's local messaging to an audience unfamiliar with the Critter Stack. + +--- + +## 6. License Summary + +| Repo | License | Safe to Use | +|------|---------|-------------| +| `oskardudycz/EventSourcing.NetCore` | MIT | ✅ | +| `kgrzybek/modular-monolith-with-ddd` | MIT | ✅ | +| `devmentors/Inflow` | MIT | ✅ | +| `meysamhadeli/booking-modular-monolith` | MIT | ✅ | +| `kamilbaczek/Modular-monolith-by-example` | MIT | ✅ | +| `NeVeSpl/TestMe` | MIT | ✅ | +| `aspnetrun/run-aspnetcore-microservices` | MIT | ✅ | +| `MassTransit/Sample-ShoppingWeb` | Apache 2.0 | ✅ | +| `MassTransit/Sample-Outbox` | Apache 2.0 | ✅ | +| `MassTransit/Sample-Batch` | Apache 2.0 | ✅ | +| `ebubekirdinc/SuuCat` | MIT | ✅ | +| `jasontaylordev/CleanArchitecture` | MIT | ✅ | +| `jasontaylordev/NorthwindTraders` | MIT | ✅ | +| `ardalis/CleanArchitecture` | MIT | ✅ | +| `kgrzybek/sample-dotnet-core-cqrs-api` | MIT | ✅ | +| `gothinkster/aspnetcore-realworld-example-app` | MIT | ✅ | +| `mehdihadeli/vertical-slice-api-template` | MIT | ✅ | +| `EduardoPires/EquinoxProject` | MIT | ✅ | +| `matjazbravc/CQRS.MinimalAPI.Demo` | MIT | ✅ | +| `isaacOjeda/MinimalApiArchitecture` | MIT | ✅ | +| `CharlieDigital/dn8-modular-monolith` | MIT | ✅ | +| `devmentors/Inflow` | MIT | ✅ | + +### Licensing Advisories + +- **MassTransit v9** has moved to a commercial license. All MassTransit samples listed here use v8 (Apache 2.0), which is supported through at least end of 2026. New projects should use Wolverine instead. +- **MediatR** has announced a move to commercial licensing (April 2025). The sample repos listed here are MIT licensed and remain safe to use; MediatR as a dependency is what will require attention. +- **NServiceBus** is RPL 1.5 licensed (not permissive). No NServiceBus sample applications are listed here as primary targets; the framework would be replaced entirely in any migration scenario. + +--- + +## Recommended Starting Order + +For building out a Critter Stack migration showcase, the following progression makes sense: + +1. **`matjazbravc/CQRS.MinimalAPI.Demo`** — Smallest possible MediatR → Wolverine port. Establishes the pattern. +2. **`jasontaylordev/CleanArchitecture`** — Most recognizable MediatR codebase. Demonstrates the "unraveling" story at a widely-known template. +3. **`MassTransit/Sample-Outbox`** — Focused outbox comparison. Wolverine's outbox is a flagship differentiator. +4. **`aspnetrun/run-aspnetcore-microservices`** — Replaces both MassTransit and MediatR; already uses Marten. Highest real-world relevance. +5. **`kgrzybek/modular-monolith-with-ddd`** — Full modular monolith migration. Demonstrates Marten event sourcing + Wolverine messaging replacing custom infrastructure. +6. **`devmentors/Inflow`** — Rich payments domain; event-driven module integration via Wolverine transports. +7. **`meysamhadeli/booking-modular-monolith`** — Has event sourcing already in place; MassTransit + MediatR both replaced by Wolverine. + +--- + +*Generated: April 2026* diff --git a/wolverine.slnx b/wolverine.slnx index 960e10bc6..5d79cc497 100644 --- a/wolverine.slnx +++ b/wolverine.slnx @@ -25,12 +25,15 @@ + + +