From a8887a53a9a194b645cff5a3a6687e168ef7c13c Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 2 Aug 2026 11:35:38 -0500 Subject: [PATCH] Adopt compliance wave 2: retire four more hand-mirrored test pairs (#5118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #5110 / program #5119. Moves four Marten test files onto the shared JasperFx.Events.ComplianceTests suites (2.37.2) and deletes them here, the same trade #5122 made for the first four. Retired, and what replaced each: - Aggregation/auto_discover_aggregate_types.cs -> AutoDiscoveredAggregateCompliance - Projections/event_projection_should_register_document_types.cs -> EventProjectionRegistrationCompliance - Projections/event_projection_enrichment_tests.cs -> EventProjectionEnrichmentCompliance - rebuild_concurrency_cap_resolution.cs -> RebuildConcurrencyCapCompliance Coverage went up, not down. Marten's registration test only asserted that AuditRecord reached AllKnownDocumentTypes(); Polecat's only asserted PublishedTypes(). The shared suite asserts both routes and adds an end-to-end append proving the store really provisioned storage for a document type nobody registered — the actual point of #4166. MartenComplianceFixture picks up the seam members the new suites need: StoreDocument, EventStore, AllAggregateTypes, an AddProjection registrar member, and connection-string/DaemonSettings handling for the rebuild-cap knobs. The harness alias file gains ComplianceOperations and ComplianceEventProjection beside the existing ComplianceQuerySession, because the EventProjection suites declare projection types at file scope and cannot reach the suite's generics. EventSourcingTests net9.0: 1614/0/7 (was 1613/0/7 — twelve local tests out, thirteen compliance tests in). Compliance namespace alone: 55/55, no capability gates. Blocked on the JasperFx 2.37.2 publish (jasperfx PR). Verified locally against a packed prerelease of the same sources. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VpDCvJcBDZerieJB4JEHde --- Directory.Packages.props | 15 +- .../auto_discover_aggregate_types.cs | 49 ---- .../marten_event_store_compliance.cs | 12 + .../event_projection_enrichment_tests.cs | 220 ------------------ ...ojection_should_register_document_types.cs | 114 --------- .../rebuild_concurrency_cap_resolution.cs | 69 ------ .../Harness/ComplianceQuerySessionAlias.cs | 5 + .../Harness/MartenComplianceFixture.cs | 30 ++- 8 files changed, 55 insertions(+), 459 deletions(-) delete mode 100644 src/EventSourcingTests/Aggregation/auto_discover_aggregate_types.cs delete mode 100644 src/EventSourcingTests/Projections/event_projection_enrichment_tests.cs delete mode 100644 src/EventSourcingTests/Projections/event_projection_should_register_document_types.cs delete mode 100644 src/EventSourcingTests/rebuild_concurrency_cap_resolution.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 37aabf94fd..c4d3518eea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -171,21 +171,24 @@ message, since descriptions ship to monitoring consoles). Also carries the jasperfx#594 patch half (a timed-out blue/green side-effect gate re-reads progression and succeeds when the replay had already reached the mark, plus a configurable DaemonSettings.SideEffectGateTimeout), - jasperfx#595 (BatchingChannel could deliver its trailing batch twice on shutdown) and #597. --> - - + jasperfx#595 (BatchingChannel could deliver its trailing batch twice on shutdown) and #597. + JasperFx 2.37.2: compliance wave 2 (marten#5118) — four more shared suites plus the seam + members they need. Nothing outside JasperFx.Events.ComplianceTests changed; the other + packages move only because the line versions together. --> + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/EventSourcingTests/Aggregation/auto_discover_aggregate_types.cs b/src/EventSourcingTests/Aggregation/auto_discover_aggregate_types.cs deleted file mode 100644 index 2fea327da3..0000000000 --- a/src/EventSourcingTests/Aggregation/auto_discover_aggregate_types.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using JasperFx.Events; -using JasperFx.Events.ComplianceTests; -using Marten; -using Marten.Testing.Harness; -using Shouldly; -using Xunit; - -namespace EventSourcingTests.Aggregation; - -/// -/// Tests that verify self-aggregating types with source-generated evolvers -/// are automatically discovered and registered in StoreOptions.Projections -/// even without explicit Snapshot<T>() registration. -/// -public class auto_discover_aggregate_types : IntegrationContext -{ - public auto_discover_aggregate_types(DefaultStoreFixture fixture) : base(fixture) - { - } - - [Fact] - public void self_aggregating_types_are_auto_discovered() - { - // These types have source-generated evolvers via Apply/Create methods - // but are NOT explicitly registered via Projections.Snapshot() - var aggregateTypes = theStore.Options.Projections.AllAggregateTypes().ToArray(); - - // MutableIEventEvolveAggregate has a generated evolver via Evolve(IEvent) - aggregateTypes.ShouldContain(typeof(MutableIEventEvolveAggregate)); - } - - [Fact] - public async Task auto_discovered_type_works_for_live_aggregation() - { - // No explicit Snapshot() registration — relies on auto-discovery - var streamId = Guid.NewGuid(); - theSession.Events.StartStream(streamId, new EvolveAEvent(), new EvolveBEvent(), new EvolveCEvent()); - await theSession.SaveChangesAsync(); - - var aggregate = await theSession.Events.AggregateStreamAsync(streamId); - aggregate.ShouldNotBeNull(); - aggregate.ACount.ShouldBe(1); - aggregate.BCount.ShouldBe(1); - aggregate.CCount.ShouldBe(1); - } -} diff --git a/src/EventSourcingTests/Compliance/marten_event_store_compliance.cs b/src/EventSourcingTests/Compliance/marten_event_store_compliance.cs index 67aa4d9f49..7435f2b11b 100644 --- a/src/EventSourcingTests/Compliance/marten_event_store_compliance.cs +++ b/src/EventSourcingTests/Compliance/marten_event_store_compliance.cs @@ -22,3 +22,15 @@ public class assign_tag_where_compliance public class async_daemon_compliance : AsyncDaemonCompliance; + +public class auto_discovered_aggregate_compliance + : AutoDiscoveredAggregateCompliance; + +public class event_projection_registration_compliance + : EventProjectionRegistrationCompliance; + +public class event_projection_enrichment_compliance + : EventProjectionEnrichmentCompliance; + +public class rebuild_concurrency_cap_compliance + : RebuildConcurrencyCapCompliance; diff --git a/src/EventSourcingTests/Projections/event_projection_enrichment_tests.cs b/src/EventSourcingTests/Projections/event_projection_enrichment_tests.cs deleted file mode 100644 index 9fba37b418..0000000000 --- a/src/EventSourcingTests/Projections/event_projection_enrichment_tests.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JasperFx.Events; -using JasperFx.Events.Projections; -using Marten; -using Marten.Events.Projections; -using Marten.Testing.Documents; -using Marten.Testing.Harness; -using Shouldly; -using Xunit; - -namespace EventSourcingTests.Projections; - -public class event_projection_enrichment_tests : OneOffConfigurationsContext -{ - [Fact] - public async Task enrichment_sets_data_before_apply_inline() - { - StoreOptions(opts => - { - opts.Projections.Add(new SimpleEnrichmentProjection(), ProjectionLifecycle.Inline); - }); - - await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); - - // Use TaskAssigned as the only event — enrichment sets UserName, - // ProjectAsync reads it and stores a TaskSummary - var taskId = Guid.NewGuid(); - await using (var session = theStore.LightweightSession()) - { - session.Events.StartStream(taskId, - new TaskAssigned { TaskId = taskId, UserId = Guid.NewGuid() }); - await session.SaveChangesAsync(); - } - - await using (var query = theStore.QuerySession()) - { - var summary = await query.LoadAsync(taskId); - summary.ShouldNotBeNull(); - // The enrichment hardcodes the name — if set, enrichment ran before Apply - summary.AssignedUserName.ShouldBe("Enriched User"); - } - } - - [Fact] - public async Task enrichment_with_database_lookup_inline() - { - StoreOptions(opts => - { - opts.Projections.Add(new DatabaseLookupEnrichmentProjection(), ProjectionLifecycle.Inline); - }); - - await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); - - // Pre-store a User - var userId = Guid.NewGuid(); - await using (var session = theStore.LightweightSession()) - { - session.Store(new User { Id = userId, FirstName = "Alice", LastName = "Smith" }); - await session.SaveChangesAsync(); - } - - var taskId = Guid.NewGuid(); - await using (var session = theStore.LightweightSession()) - { - session.Events.StartStream(taskId, - new TaskAssigned { TaskId = taskId, UserId = userId }); - await session.SaveChangesAsync(); - } - - await using (var query = theStore.QuerySession()) - { - var summary = await query.LoadAsync(taskId); - summary.ShouldNotBeNull(); - summary.AssignedUserName.ShouldBe("Alice Smith"); - } - } - - [Fact] - public async Task enrichment_is_called_before_apply() - { - var callOrder = new List(); - - StoreOptions(opts => - { - opts.Projections.Add( - new CallOrderTrackingProjection(callOrder), - ProjectionLifecycle.Inline); - }); - - await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); - - var streamId = Guid.NewGuid(); - await using var session = theStore.LightweightSession(); - session.Events.StartStream(streamId, new TaskCreated { TaskId = streamId, Title = "Test" }); - await session.SaveChangesAsync(); - - callOrder.ShouldBe(new[] { "EnrichEventsAsync", "Apply:TaskCreated" }); - } -} - -#region Test Events - -public class TaskCreated -{ - public Guid TaskId { get; set; } - public string Title { get; set; } = ""; -} - -public class TaskAssigned -{ - public Guid TaskId { get; set; } - public Guid UserId { get; set; } - public string? UserName { get; set; } -} - -#endregion - -#region Test Documents - -public class TaskSummary -{ - public Guid Id { get; set; } - public string Title { get; set; } = ""; - public string? AssignedUserName { get; set; } -} - -#endregion - -#region Simple Enrichment (no DB lookup) - -public partial class SimpleEnrichmentProjection : EventProjection -{ - // TaskAssigned handler reads UserName that was set by EnrichEventsAsync - public void Project(TaskAssigned e, IDocumentOperations ops) - { - ops.Store(new TaskSummary - { - Id = e.TaskId, - AssignedUserName = e.UserName - }); - } - - public override Task EnrichEventsAsync(IQuerySession querySession, - IReadOnlyList events, CancellationToken cancellation) - { - foreach (var e in events.OfType>()) - { - e.Data.UserName = "Enriched User"; - } - return Task.CompletedTask; - } -} - -#endregion - -#region Database Lookup Enrichment - -public partial class DatabaseLookupEnrichmentProjection : EventProjection -{ - // Stores a TaskSummary using the enriched UserName - public void Project(TaskAssigned e, IDocumentOperations ops) - { - ops.Store(new TaskSummary - { - Id = e.TaskId, - AssignedUserName = e.UserName - }); - } - - public override async Task EnrichEventsAsync(IQuerySession querySession, - IReadOnlyList events, CancellationToken cancellation) - { - var assigned = events.OfType>().ToArray(); - if (assigned.Length == 0) return; - - var userIds = assigned.Select(e => e.Data.UserId).Distinct().ToArray(); - var users = await querySession.LoadManyAsync(cancellation, userIds); - var lookup = users.ToDictionary(u => u.Id); - - foreach (var e in assigned) - { - if (lookup.TryGetValue(e.Data.UserId, out var user)) - { - e.Data.UserName = $"{user.FirstName} {user.LastName}"; - } - } - } -} - -#endregion - -#region Call Order Tracking - -public partial class CallOrderTrackingProjection : EventProjection -{ - private readonly List _callOrder; - - public CallOrderTrackingProjection(List callOrder) - { - _callOrder = callOrder; - } - - public void Project(TaskCreated e, IDocumentOperations ops) - { - _callOrder.Add($"Apply:{nameof(TaskCreated)}"); - } - - public override Task EnrichEventsAsync(IQuerySession querySession, - IReadOnlyList events, CancellationToken cancellation) - { - _callOrder.Add(nameof(EnrichEventsAsync)); - return Task.CompletedTask; - } -} - -#endregion diff --git a/src/EventSourcingTests/Projections/event_projection_should_register_document_types.cs b/src/EventSourcingTests/Projections/event_projection_should_register_document_types.cs deleted file mode 100644 index c46775773c..0000000000 --- a/src/EventSourcingTests/Projections/event_projection_should_register_document_types.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JasperFx.Events; -using JasperFx.Events.Projections; -using Marten; -using Marten.Events.Projections; -using Marten.Testing.Harness; -using Shouldly; -using Xunit; - -namespace EventSourcingTests.Projections; - -/// -/// Document type used in an EventProjection but NOT explicitly registered with Marten. -/// The source generator should discover this type from Store/Insert calls in ApplyAsync -/// and register it automatically. -/// See https://github.com/JasperFx/marten/issues/4166 -/// -public class AuditRecord -{ - public Guid Id { get; set; } - public Guid StreamId { get; set; } - public string EventType { get; set; } = string.Empty; - public DateTimeOffset Timestamp { get; set; } -} - -public class AuditableEvent -{ - public string Description { get; set; } = string.Empty; -} - -/// -/// An EventProjection with an explicit ApplyAsync override that stores a document type -/// using operations.Store<T>(). The source generator should detect this and emit a -/// constructor that registers AuditRecord as a published type. -/// See https://github.com/JasperFx/marten/issues/4166 -/// -public partial class AuditRecordProjection : EventProjection -{ - public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, CancellationToken cancellation) - { - switch (e.Data) - { - case AuditableEvent: - operations.Store(new AuditRecord - { - Id = Guid.NewGuid(), - StreamId = e.StreamId, - EventType = e.Data.GetType().Name, - Timestamp = e.Timestamp - }); - break; - } - - return new ValueTask(); - } -} - -/// -/// An EventProjection with conventional Create method that returns a document type. -/// The source generator should register this type via the emitted constructor. -/// -public partial class AuditRecordCreatorProjection : EventProjection -{ - public AuditRecord Create(AuditableEvent e) => new AuditRecord - { - Id = Guid.NewGuid(), - EventType = nameof(AuditableEvent) - }; -} - -public class event_projection_should_register_document_types : OneOffConfigurationsContext -{ - [Fact] - public void explicit_apply_async_projection_should_register_document_types() - { - // Issue #4166: Document types used in operations.Store() inside an explicit - // ApplyAsync override should be automatically discovered and registered. - // 9.0: AllDocumentMappings is lazy now (#4303). Use the public - // AllKnownDocumentTypes() API which triggers full materialization. - StoreOptions(opts => - { - opts.Projections.Add(new AuditRecordProjection(), ProjectionLifecycle.Inline); - }); - - var documentTypes = ((IReadOnlyStoreOptions)theStore.Options) - .AllKnownDocumentTypes() - .Select(x => x.DocumentType).ToList(); - - documentTypes.ShouldContain(typeof(AuditRecord), - "AuditRecord should be auto-discovered from operations.Store() in ApplyAsync"); - } - - [Fact] - public void conventional_create_projection_should_register_document_types() - { - // EventProjection with Create method should also register the return type. - // 9.0: as above — AllDocumentMappings is lazy, AllKnownDocumentTypes() - // is the materialize-then-snapshot accessor. - StoreOptions(opts => - { - opts.Projections.Add(new AuditRecordCreatorProjection(), ProjectionLifecycle.Inline); - }); - - var documentTypes = ((IReadOnlyStoreOptions)theStore.Options) - .AllKnownDocumentTypes() - .Select(x => x.DocumentType).ToList(); - - documentTypes.ShouldContain(typeof(AuditRecord), - "AuditRecord should be auto-discovered from Create method return type"); - } -} diff --git a/src/EventSourcingTests/rebuild_concurrency_cap_resolution.cs b/src/EventSourcingTests/rebuild_concurrency_cap_resolution.cs deleted file mode 100644 index d4229484d5..0000000000 --- a/src/EventSourcingTests/rebuild_concurrency_cap_resolution.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using JasperFx.Events; -using Marten; -using Marten.Testing.Harness; -using Npgsql; -using Shouldly; -using Xunit; - -namespace EventSourcingTests; - -// jasperfx#420 / marten#4710: resolution of the per-database rebuild concurrency cap -// surfaced through IEventStore.MaxConcurrentRebuildsPerDatabase. -public class rebuild_concurrency_cap_resolution: OneOffConfigurationsContext -{ - [Fact] - public void configured_value_wins_over_derived_default() - { - var store = SeparateStore(opts => opts.Projections.MaxConcurrentRebuildsPerDatabase = 3); - ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(3); - } - - [Fact] - public void non_positive_configured_value_disables_the_cap() - { - var store = SeparateStore(opts => opts.Projections.MaxConcurrentRebuildsPerDatabase = 0); - ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBeNull(); - } - - [Fact] - public void derived_default_is_pool_size_over_eight_with_floor_of_one() - { - var connectionString = new NpgsqlConnectionStringBuilder(ConnectionSource.ConnectionString) - { - MaxPoolSize = 64 - }.ConnectionString; - - var store = SeparateStore(opts => opts.Connection(connectionString)); - - ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(8); - } - - [Fact] - public void derived_default_floors_at_one_for_tiny_pools() - { - var connectionString = new NpgsqlConnectionStringBuilder(ConnectionSource.ConnectionString) - { - MaxPoolSize = 5 - }.ConnectionString; - - var store = SeparateStore(opts => opts.Connection(connectionString)); - - ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(1); - } - - [Fact] - public async Task usage_descriptor_carries_the_effective_cap() - { - // jasperfx#434: CritterWatch#309's rebuild dispatcher reads the effective cap - // off the EventStoreUsage descriptor rather than guessing. - var store = SeparateStore(opts => opts.Projections.MaxConcurrentRebuildsPerDatabase = 6); - - var usage = await ((IEventStore)store).TryCreateUsage(CancellationToken.None); - - usage.ShouldNotBeNull(); - usage.MaxConcurrentRebuildsPerDatabase.ShouldBe(6); - } -} diff --git a/src/Marten.Testing/Harness/ComplianceQuerySessionAlias.cs b/src/Marten.Testing/Harness/ComplianceQuerySessionAlias.cs index 6a1f34a9c6..decec7b053 100644 --- a/src/Marten.Testing/Harness/ComplianceQuerySessionAlias.cs +++ b/src/Marten.Testing/Harness/ComplianceQuerySessionAlias.cs @@ -3,3 +3,8 @@ // by type name, so a per-consumer global alias lets one shared source file bind to Marten's // IQuerySession here and to Polecat's in Polecat. global using ComplianceQuerySession = Marten.IQuerySession; + +// Same mechanism for the EventProjection suites. Those declare projection types at file scope, so +// they cannot reach the pair their suite class is generic over. +global using ComplianceOperations = Marten.IDocumentOperations; +global using ComplianceEventProjection = Marten.Events.Projections.EventProjection; diff --git a/src/Marten.Testing/Harness/MartenComplianceFixture.cs b/src/Marten.Testing/Harness/MartenComplianceFixture.cs index 7d479f1b66..1ecdd07017 100644 --- a/src/Marten.Testing/Harness/MartenComplianceFixture.cs +++ b/src/Marten.Testing/Harness/MartenComplianceFixture.cs @@ -10,6 +10,7 @@ using JasperFx.Events.Tags; using Marten.Events; using Marten.Services.BatchQuerying; +using Npgsql; namespace Marten.Testing.Harness; @@ -27,12 +28,17 @@ public class MartenComplianceFixture: EventStoreComplianceFixture _store.LightweightSession(); // No shared JasperFx interface declares SaveChangesAsync -- in Marten it lives on @@ -62,8 +81,14 @@ public override Task SaveChangesAsync(IDocumentOperations session, CancellationT $"Marten cannot load documents by an identity of type {id.GetType().FullName}") }; + public override void StoreDocument(IDocumentOperations session, T document) => session.Store(document); + public override JasperFx.Events.IEventStoreOperations EventsFor(IDocumentOperations session) => session.Events; + public override IEventStore EventStore => _store; + + public override IEnumerable AllAggregateTypes() => _store.Options.Projections.AllAggregateTypes(); + public override IComplianceBatch CreateBatch(IQuerySession session) => new MartenComplianceBatch(session.CreateBatchQuery()); @@ -125,6 +150,9 @@ public void Snapshot(SnapshotLifecycle lifecycle) where TDoc : notnull public void LiveAggregation() where TDoc : notnull => _options.Projections.LiveStreamAggregation(); + + public void AddProjection(ProjectionBase projection, ProjectionLifecycle lifecycle) + => _options.Projections.Add((IProjectionSource)projection, lifecycle); } internal class MartenComplianceBatch: IComplianceBatch