From f348c93522f8e71cb99e9194764eddc144ba9a6a Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Fri, 10 Apr 2026 17:09:50 +0200 Subject: [PATCH 1/5] WIP: first set of features for EventCatalog --- Papst.EventStore.slnx | 1 + samples/SampleEventCatalog/Events.cs | 37 ++ samples/SampleEventCatalog/Program.cs | 81 +++ .../SampleEventCatalog.csproj | 17 + .../EventNameAttribute.cs | 57 ++ ...entRegistrationIncrementalCodeGenerator.cs | 591 +++++++++++++++++- .../EventCatalog/EventCatalogEntry.cs | 9 + .../EventCatalog/EventCatalogEventDetails.cs | 10 + .../EventCatalog/EventCatalogProvider.cs | 35 ++ .../EventCatalog/EventCatalogRegistration.cs | 69 ++ .../EventCatalog/IEventCatalog.cs | 23 + .../EventCatalog/IEventCatalogRegistration.cs | 27 + .../CodeGeneratorTests.cs | 156 +++++ .../EventCatalog/EventCatalogProviderTests.cs | 184 ++++++ 14 files changed, 1266 insertions(+), 31 deletions(-) create mode 100644 samples/SampleEventCatalog/Events.cs create mode 100644 samples/SampleEventCatalog/Program.cs create mode 100644 samples/SampleEventCatalog/SampleEventCatalog.csproj create mode 100644 src/Papst.EventStore/EventCatalog/EventCatalogEntry.cs create mode 100644 src/Papst.EventStore/EventCatalog/EventCatalogEventDetails.cs create mode 100644 src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs create mode 100644 src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs create mode 100644 src/Papst.EventStore/EventCatalog/IEventCatalog.cs create mode 100644 src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs create mode 100644 tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs diff --git a/Papst.EventStore.slnx b/Papst.EventStore.slnx index 0c05f62..f95c392 100644 --- a/Papst.EventStore.slnx +++ b/Papst.EventStore.slnx @@ -11,6 +11,7 @@ + diff --git a/samples/SampleEventCatalog/Events.cs b/samples/SampleEventCatalog/Events.cs new file mode 100644 index 0000000..f00d905 --- /dev/null +++ b/samples/SampleEventCatalog/Events.cs @@ -0,0 +1,37 @@ +using Papst.EventStore.Aggregation.EventRegistration; + +namespace SampleEventCatalog; + +// Entity types +public record User(Guid Id, string Name, string Email, bool IsActive); +public record Order(Guid Id, Guid UserId, decimal Total, OrderStatus Status, List Items); +public record OrderItem(string ProductName, int Quantity, decimal Price); + +public enum OrderStatus +{ + Pending, + Confirmed, + Shipped, + Delivered, + Cancelled +} + +// User events +[EventName("UserCreated", Description = "Raised when a new user is created", Constraints = new[] { "Create" })] +public record UserCreatedEvent(string Name, string Email); + +[EventName("UserRenamed", Description = "Raised when a user changes their name", Constraints = new[] { "Update" })] +public record UserRenamedEvent(string NewName); + +[EventName("UserDeactivated", Description = "Raised when a user is deactivated", Constraints = new[] { "Update", "Admin" })] +public record UserDeactivatedEvent(string Reason); + +// Order events +[EventName("OrderPlaced", Description = "Raised when a new order is placed", Constraints = new[] { "Create" })] +public record OrderPlacedEvent(Guid UserId, List Items, decimal Total); + +[EventName("OrderStatusChanged", Description = "Raised when order status changes", Constraints = new[] { "Update" })] +public record OrderStatusChangedEvent(OrderStatus NewStatus); + +[EventName("OrderCancelled", Description = "Raised when an order is cancelled", Constraints = new[] { "Delete" })] +public record OrderCancelledEvent(string CancellationReason, DateTime CancelledAt); diff --git a/samples/SampleEventCatalog/Program.cs b/samples/SampleEventCatalog/Program.cs new file mode 100644 index 0000000..fd8a82d --- /dev/null +++ b/samples/SampleEventCatalog/Program.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.DependencyInjection; +using Papst.EventStore.EventCatalog; + +namespace SampleEventCatalog; + +public class Program +{ + public static void Main(string[] args) + { + var services = new ServiceCollection(); + + // Register both generated events and catalog + services.AddCodeGeneratedEvents(); + services.AddCodeGeneratedEventCatalog(); + + var serviceProvider = services.BuildServiceProvider(); + var catalog = serviceProvider.GetRequiredService(); + + Console.WriteLine("=== Event Catalog Sample ==="); + Console.WriteLine(); + + // List all events for User entity + Console.WriteLine("--- Events for User entity ---"); + var userEvents = catalog.ListEvents(); + foreach (var entry in userEvents) + { + Console.WriteLine($" Event: {entry.EventName}"); + Console.WriteLine($" Description: {entry.Description ?? "(none)"}"); + Console.WriteLine($" Constraints: {(entry.Constraints != null ? string.Join(", ", entry.Constraints) : "(none)")}"); + Console.WriteLine(); + } + + // List all events for Order entity + Console.WriteLine("--- Events for Order entity ---"); + var orderEvents = catalog.ListEvents(); + foreach (var entry in orderEvents) + { + Console.WriteLine($" Event: {entry.EventName}"); + Console.WriteLine($" Description: {entry.Description ?? "(none)"}"); + Console.WriteLine($" Constraints: {(entry.Constraints != null ? string.Join(", ", entry.Constraints) : "(none)")}"); + Console.WriteLine(); + } + + // Filter events by constraint + Console.WriteLine("--- User events with 'Update' constraint ---"); + var updateEvents = catalog.ListEvents(constraints: new[] { "Update" }); + foreach (var entry in updateEvents) + { + Console.WriteLine($" Event: {entry.EventName} ({entry.Description})"); + } + Console.WriteLine(); + + // Filter events by name + Console.WriteLine("--- Looking up 'UserCreated' event ---"); + var filtered = catalog.ListEvents(name: "UserCreated"); + foreach (var entry in filtered) + { + Console.WriteLine($" Found: {entry.EventName} - {entry.Description}"); + } + Console.WriteLine(); + + // Get event details with JSON schema + Console.WriteLine("--- Event Details with JSON Schema ---"); + var details = catalog.GetEventDetails("OrderPlaced"); + if (details != null) + { + Console.WriteLine($" Event: {details.EventName}"); + Console.WriteLine($" Description: {details.Description}"); + Console.WriteLine($" Constraints: {(details.Constraints != null ? string.Join(", ", details.Constraints) : "(none)")}"); + Console.WriteLine($" JSON Schema: {details.JsonSchema}"); + } + Console.WriteLine(); + + Console.WriteLine("--- JSON Schema for UserCreatedEvent ---"); + var userCreatedDetails = catalog.GetEventDetails("UserCreated"); + if (userCreatedDetails != null) + { + Console.WriteLine($" {userCreatedDetails.JsonSchema}"); + } + } +} diff --git a/samples/SampleEventCatalog/SampleEventCatalog.csproj b/samples/SampleEventCatalog/SampleEventCatalog.csproj new file mode 100644 index 0000000..54f60c5 --- /dev/null +++ b/samples/SampleEventCatalog/SampleEventCatalog.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/Papst.EventStore.Aggregation.EventRegistration/EventNameAttribute.cs b/src/Papst.EventStore.Aggregation.EventRegistration/EventNameAttribute.cs index a5cd11a..b073ee0 100644 --- a/src/Papst.EventStore.Aggregation.EventRegistration/EventNameAttribute.cs +++ b/src/Papst.EventStore.Aggregation.EventRegistration/EventNameAttribute.cs @@ -19,6 +19,16 @@ public sealed class EventNameAttribute : Attribute /// public bool IsWriteName { get; } + /// + /// Optional description of the Event + /// + public string? Description { get; set; } + + /// + /// Optional constraints associated with the Event + /// + public string[]? Constraints { get; set; } + /// /// Mark the Event with Read/Write information /// @@ -37,3 +47,50 @@ public EventNameAttribute(string name, bool isWriteName) public EventNameAttribute(string name) : this(name, true) { } } + +/// +/// Marks an Event Sourcing Event associated with entity +/// If is true, the Property is used to write Events +/// +/// The Entity Type this Event is associated with +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public sealed class EventNameAttribute : Attribute +{ + /// + /// Name of the Event + /// + public string Name { get; } + + /// + /// Flag whether the Attribute is used for Writing the Name + /// + public bool IsWriteName { get; } + + /// + /// Optional description of the Event + /// + public string? Description { get; set; } + + /// + /// Optional constraints associated with the Event + /// + public string[]? Constraints { get; set; } + + /// + /// Mark the Event with Read/Write information + /// + /// + /// + public EventNameAttribute(string name, bool isWriteName) + { + Name = name; + IsWriteName = isWriteName; + } + + /// + /// Mark the Event with the as Write Attribute + /// + /// + public EventNameAttribute(string name) : this(name, true) + { } +} diff --git a/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs b/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs index 7303bb5..c38db4d 100644 --- a/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs +++ b/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs @@ -18,6 +18,39 @@ public class EventRegistrationIncrementalCodeGenerator : IIncrementalGenerator private const string EventAggregatorInterfaceName = "IEventAggregator"; private const string ClassName = "EventStoreEventAggregator"; + private class EventAttributeInfo + { + public string Name { get; set; } + public bool IsWrite { get; set; } + public string Description { get; set; } + public List Constraints { get; set; } + public string EntityTypeName { get; set; } + public string EntityTypeNamespace { get; set; } + public bool IsGeneric { get; set; } + } + + private class EventInfo + { + public string ClassName { get; set; } + public string Namespace { get; set; } + public List Attributes { get; set; } = new List(); + } + + private class AggregatorTypeArg + { + public string Entity { get; set; } + public string EntityNamespace { get; set; } + public string Event { get; set; } + public string EventNamespace { get; set; } + } + + private class AggregatorInfo + { + public string Class { get; set; } + public string Namespace { get; set; } + public List TypeArguments { get; set; } = new List(); + } + public void Initialize(IncrementalGeneratorInitializationContext context) { context.RegisterSourceOutput(context.CompilationProvider, (productionContext, compilation) => @@ -49,7 +82,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .ToArray(); var events = allClasses - .Select(c => FindEvents(compilation, c)) + .Select(c => FindEvents(compilation, c, allClasses)) .Where(c => c != null) .ToList(); @@ -62,7 +95,7 @@ bt.Type is GenericNameSyntax && ((GenericNameSyntax)bt.Type).Identifier.ValueText == EventAggregatorInterfaceName) ) ) - .Select(c => new + .Select(c => new AggregatorInfo { Class = c.Identifier.ValueText, Namespace = FindNamespace(c), @@ -76,13 +109,14 @@ bt.Type is GenericNameSyntax && Entity = ((IdentifierNameSyntax)((GenericNameSyntax)bt.Type).TypeArgumentList.Arguments[0]).Identifier.ValueText, Event = ((IdentifierNameSyntax)((GenericNameSyntax)bt.Type).TypeArgumentList.Arguments[1]).Identifier.ValueText }) - .Select(bt => new + .Select(bt => new AggregatorTypeArg { Entity = bt.Entity, EntityNamespace = FindNamespace(bt.Entity, allClasses), Event = bt.Event, EventNamespace = FindNamespace(bt.Event, allClasses) }) + .ToList() }) .ToList(); @@ -99,6 +133,14 @@ bt.Type is GenericNameSyntax && .AppendLine($"namespace {baseNamespace};") .AppendLine($"public static class {ClassName}") .AppendLine("{") + ; + + // --- Generate schema fields for catalog --- + var catalogEntries = BuildCatalogEntries(events, aggregators, compilation, allClasses); + var schemaFields = GenerateSchemaFields(catalogEntries, compilation, allClasses, builder); + + // --- AddCodeGeneratedEvents (existing behavior) --- + builder .AppendLine(" public static IServiceCollection AddCodeGeneratedEvents(this IServiceCollection services)") .AppendLine(" {") ; @@ -108,7 +150,7 @@ bt.Type is GenericNameSyntax && builder.AppendLine(" var registration = new Papst.EventStore.EventRegistration.EventDescriptionEventRegistration();"); foreach (var evt in events) { - builder.AppendLine($" registration.AddEvent<{evt.Value.NameSpace}.{evt.Value.Name}>({string.Join(", ", evt.Value.Attributes.Select(attr => $"new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"{attr.Name}\", {(attr.IsWrite ? bool.TrueString.ToLower() : bool.FalseString.ToLower())})"))});"); + builder.AppendLine($" registration.AddEvent<{evt.Namespace}.{evt.ClassName}>({string.Join(", ", evt.Attributes.Select(attr => $"new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"{attr.Name}\", {(attr.IsWrite ? bool.TrueString.ToLower() : bool.FalseString.ToLower())})"))});"); } builder.AppendLine(" services.AddSingleton(registration);"); } @@ -132,10 +174,15 @@ bt.Type is GenericNameSyntax && ; builder.AppendLine(" return services;"); - builder - .AppendLine(" }") - .AppendLine("}") - ; + builder.AppendLine(" }"); + + // --- AddCodeGeneratedEventCatalog (new) --- + if (catalogEntries.Count > 0) + { + GenerateCatalogMethod(builder, catalogEntries, schemaFields); + } + + builder.AppendLine("}"); productionContext.AddSource("EventRegistration.g.cs", builder.ToString()); } @@ -167,15 +214,330 @@ bt.Type is GenericNameSyntax && }); } + #region Catalog Generation + + private class CatalogEntry + { + public string EntityNamespace { get; set; } + public string EntityName { get; set; } + public string EventName { get; set; } + public string Description { get; set; } + public List Constraints { get; set; } + public string EventClassName { get; set; } + public string EventClassNamespace { get; set; } + } + + private static List BuildCatalogEntries( + List events, + List aggregators, + Compilation compilation, + TypeDeclarationSyntax[] allClasses) + { + var entries = new List(); + var seen = new HashSet(); + + // From generic EventNameAttribute + foreach (var evt in events) + { + foreach (var attr in evt.Attributes.Where(a => a.IsGeneric)) + { + string key = $"{attr.EntityTypeNamespace}.{attr.EntityTypeName}|{attr.Name}"; + if (seen.Add(key)) + { + entries.Add(new CatalogEntry + { + EntityNamespace = attr.EntityTypeNamespace, + EntityName = attr.EntityTypeName, + EventName = attr.Name, + Description = attr.Description, + Constraints = attr.Constraints, + EventClassName = evt.ClassName, + EventClassNamespace = evt.Namespace + }); + } + } + } + + // From aggregators + foreach (var aggregator in aggregators) + { + foreach (var impl in aggregator.TypeArguments) + { + string entityNs = impl.EntityNamespace; + string entityName = impl.Entity; + string eventNs = impl.EventNamespace; + string eventName = impl.Event; + + // Find the event info to get attribute metadata + var eventInfo = events.FirstOrDefault(e => e.ClassName == eventName && e.Namespace == eventNs); + if (eventInfo != null) + { + foreach (var attr in eventInfo.Attributes) + { + string key = $"{entityNs}.{entityName}|{attr.Name}"; + if (seen.Add(key)) + { + entries.Add(new CatalogEntry + { + EntityNamespace = entityNs, + EntityName = entityName, + EventName = attr.Name, + Description = attr.Description, + Constraints = attr.Constraints, + EventClassName = eventInfo.ClassName, + EventClassNamespace = eventInfo.Namespace + }); + } + } + } + } + } + + return entries; + } + + private static Dictionary GenerateSchemaFields( + List catalogEntries, + Compilation compilation, + TypeDeclarationSyntax[] allClasses, + StringBuilder builder) + { + var schemaFields = new Dictionary(); + + foreach (var entry in catalogEntries) + { + string typeKey = $"{entry.EventClassNamespace}.{entry.EventClassName}"; + if (schemaFields.ContainsKey(typeKey)) + { + continue; + } + + string fieldName = $"_schema_{typeKey.Replace(".", "_")}"; + schemaFields[typeKey] = fieldName; + + var typeDecl = allClasses.FirstOrDefault(c => + c.Identifier.ValueText == entry.EventClassName && + FindNamespace(c) == entry.EventClassNamespace); + + string schemaJson = "{}"; + if (typeDecl != null) + { + schemaJson = GenerateJsonSchema(compilation, typeDecl); + } + + string verbatimLiteral = ToVerbatimStringLiteral(schemaJson); + builder.AppendLine($" private static readonly System.Lazy {fieldName} = new System.Lazy(() => {verbatimLiteral});"); + } + + return schemaFields; + } + + private static void GenerateCatalogMethod( + StringBuilder builder, + List catalogEntries, + Dictionary schemaFields) + { + builder + .AppendLine(" public static IServiceCollection AddCodeGeneratedEventCatalog(this IServiceCollection services)") + .AppendLine(" {") + .AppendLine(" var catalog = new Papst.EventStore.EventCatalog.EventCatalogRegistration();") + ; + + foreach (var entry in catalogEntries) + { + string typeKey = $"{entry.EventClassNamespace}.{entry.EventClassName}"; + string fieldName = schemaFields[typeKey]; + + string descArg = entry.Description != null + ? $"\"{EscapeString(entry.Description)}\"" + : "null"; + + string constraintsArg; + if (entry.Constraints != null && entry.Constraints.Count > 0) + { + constraintsArg = $"new string[] {{ {string.Join(", ", entry.Constraints.Select(c => $"\"{EscapeString(c)}\""))} }}"; + } + else + { + constraintsArg = "null"; + } + + builder.AppendLine($" catalog.RegisterEvent<{entry.EntityNamespace}.{entry.EntityName}>(\"{EscapeString(entry.EventName)}\", {descArg}, {constraintsArg}, {fieldName});"); + } + + builder + .AppendLine(" services.AddSingleton(catalog);") + .AppendLine(" if (!services.Any(descriptor => descriptor.ServiceType == typeof(Papst.EventStore.EventCatalog.IEventCatalog)))") + .AppendLine(" {") + .AppendLine(" services.AddSingleton();") + .AppendLine(" }") + .AppendLine(" return services;") + .AppendLine(" }") + ; + } + + #endregion + + #region JSON Schema Generation + + private static string GenerateJsonSchema(Compilation compilation, TypeDeclarationSyntax typeDeclaration) + { + var semanticModel = compilation.GetSemanticModel(typeDeclaration.SyntaxTree); + var typeSymbol = semanticModel.GetDeclaredSymbol(typeDeclaration) as INamedTypeSymbol; + if (typeSymbol == null) + { + return "{}"; + } + return GenerateSchemaForType(typeSymbol, new HashSet()); + } + + private static string GenerateSchemaForType(ITypeSymbol type, HashSet visited) + { + // Unwrap Nullable + if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && type is INamedTypeSymbol nullable) + { + return GenerateSchemaForType(nullable.TypeArguments[0], visited); + } + + // Handle special/primitive types + switch (type.SpecialType) + { + case SpecialType.System_String: + return "{\"type\":\"string\"}"; + case SpecialType.System_Int16: + case SpecialType.System_Int32: + case SpecialType.System_Int64: + case SpecialType.System_Byte: + case SpecialType.System_UInt16: + case SpecialType.System_UInt32: + case SpecialType.System_UInt64: + case SpecialType.System_SByte: + return "{\"type\":\"integer\"}"; + case SpecialType.System_Single: + case SpecialType.System_Double: + case SpecialType.System_Decimal: + return "{\"type\":\"number\"}"; + case SpecialType.System_Boolean: + return "{\"type\":\"boolean\"}"; + case SpecialType.System_DateTime: + return "{\"type\":\"string\",\"format\":\"date-time\"}"; + case SpecialType.System_Object: + return "{}"; + } + + // Handle well-known types by display name + string fullName = type.ToDisplayString(); + if (fullName == "System.DateTimeOffset") + return "{\"type\":\"string\",\"format\":\"date-time\"}"; + if (fullName == "System.Guid") + return "{\"type\":\"string\",\"format\":\"uuid\"}"; + if (fullName == "System.TimeSpan") + return "{\"type\":\"string\",\"format\":\"duration\"}"; + if (fullName == "System.Uri") + return "{\"type\":\"string\",\"format\":\"uri\"}"; + + // Handle enums + if (type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol enumType) + { + var members = enumType.GetMembers().OfType().Where(f => f.HasConstantValue).ToList(); + var values = string.Join(",", members.Select(m => $"\"{m.Name}\"")); + return $"{{\"type\":\"string\",\"enum\":[{values}]}}"; + } + + // Handle arrays + if (type is IArrayTypeSymbol arrayType) + { + string itemSchema = GenerateSchemaForType(arrayType.ElementType, visited); + return $"{{\"type\":\"array\",\"items\":{itemSchema}}}"; + } + + // Handle generic collections + if (type is INamedTypeSymbol namedType && namedType.IsGenericType) + { + // Check for IEnumerable + var enumerableInterface = namedType.AllInterfaces + .FirstOrDefault(i => i.IsGenericType && + i.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IEnumerable"); + + string origDisplay = namedType.OriginalDefinition.ToDisplayString(); + if (enumerableInterface != null || origDisplay == "System.Collections.Generic.IEnumerable") + { + var elementType = enumerableInterface != null + ? enumerableInterface.TypeArguments[0] + : namedType.TypeArguments[0]; + string itemSchema = GenerateSchemaForType(elementType, visited); + return $"{{\"type\":\"array\",\"items\":{itemSchema}}}"; + } + + // Check for Dictionary + if (origDisplay.StartsWith("System.Collections.Generic.Dictionary") || + origDisplay.StartsWith("System.Collections.Generic.IDictionary")) + { + string valueSchema = GenerateSchemaForType(namedType.TypeArguments[1], visited); + return $"{{\"type\":\"object\",\"additionalProperties\":{valueSchema}}}"; + } + } + + // Handle object types (class/record/struct) + if (type is INamedTypeSymbol objectType && + (type.TypeKind == TypeKind.Class || type.TypeKind == TypeKind.Struct)) + { + string typeKey = objectType.ToDisplayString(); + if (visited.Contains(typeKey)) + { + return "{\"type\":\"object\"}"; + } + visited.Add(typeKey); + + var props = objectType.GetMembers() + .OfType() + .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic && p.GetMethod != null) + .ToList(); + + var sb = new StringBuilder(); + sb.Append("{\"type\":\"object\",\"properties\":{"); + bool first = true; + foreach (var prop in props) + { + if (!first) sb.Append(","); + first = false; + string propName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); + string propSchema = GenerateSchemaForType(prop.Type, visited); + sb.Append($"\"{propName}\":{propSchema}"); + } + sb.Append("}}"); + + visited.Remove(typeKey); + return sb.ToString(); + } + + // Fallback + return "{\"type\":\"object\"}"; + } + + private static string ToVerbatimStringLiteral(string value) + { + return "@\"" + value.Replace("\"", "\"\"") + "\""; + } + + private static string EscapeString(string value) + { + return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + #endregion + + #region Attribute Parsing + /// /// Checks if the is attributed with an EventNameAttribute - /// and parses the values + /// (generic or non-generic) and parses the values /// - private static (List<(string Name, bool IsWrite)> Attributes, string Name, string NameSpace)? FindEvents(Compilation compilation, TypeDeclarationSyntax typeDeclaration) + private static EventInfo FindEvents(Compilation compilation, TypeDeclarationSyntax typeDeclaration, TypeDeclarationSyntax[] allClasses) { var attributes = typeDeclaration.AttributeLists .SelectMany(x => x.Attributes) - .Where(attr => attr.Name.ToString() == "EventNameAttribute" || attr.Name.ToString() == "EventName") + .Where(attr => IsEventNameAttribute(attr.Name)) .ToList(); if (attributes.Count == 0) @@ -187,47 +549,212 @@ private static (List<(string Name, bool IsWrite)> Attributes, string Name, strin var className = typeDeclaration.Identifier.ValueText; string nsName = FindNamespace(typeDeclaration); - List<(string Name, bool IsWrite)> setAttributes = new List<(string Name, bool IsWrite)>(); + var eventInfo = new EventInfo { ClassName = className, Namespace = nsName }; + foreach (var attr in attributes) { - string name = null; - bool isWrite = true; - var expr = semanticModel.GetConstantValue(attr.ArgumentList.Arguments[0].Expression).Value; - - if (expr is string name2) + var attrInfo = ParseEventNameAttribute(attr, semanticModel, allClasses); + if (attrInfo != null) { - name = name2; + eventInfo.Attributes.Add(attrInfo); } - else if (expr is bool isWrite2) + } + + return eventInfo.Attributes.Count > 0 ? eventInfo : null; + } + + private static bool IsEventNameAttribute(NameSyntax name) + { + string simpleName = GetSimpleName(name); + return simpleName == "EventName" || simpleName == "EventNameAttribute"; + } + + private static string GetSimpleName(NameSyntax name) + { + if (name is IdentifierNameSyntax ins) + return ins.Identifier.ValueText; + if (name is GenericNameSyntax gns) + return gns.Identifier.ValueText; + if (name is QualifiedNameSyntax qns) + return GetSimpleName(qns.Right); + return ""; + } + + private static EventAttributeInfo ParseEventNameAttribute( + AttributeSyntax attr, + SemanticModel semanticModel, + TypeDeclarationSyntax[] allClasses) + { + string name = null; + bool isWrite = true; + string description = null; + List constraints = null; + bool isGeneric = false; + string entityTypeName = null; + string entityTypeNamespace = null; + + // Check if this is a generic attribute EventName + if (attr.Name is GenericNameSyntax gns && gns.TypeArgumentList.Arguments.Count == 1) + { + isGeneric = true; + var entityTypeSyntax = gns.TypeArgumentList.Arguments[0]; + var typeInfo = semanticModel.GetTypeInfo(entityTypeSyntax); + if (typeInfo.Type != null && typeInfo.Type.TypeKind != TypeKind.Error) { - isWrite = isWrite2; + entityTypeName = typeInfo.Type.Name; + entityTypeNamespace = typeInfo.Type.ContainingNamespace?.ToDisplayString(); } - - if (attr.ArgumentList.Arguments.Count > 1) + else { - expr = semanticModel.GetConstantValue(attr.ArgumentList.Arguments[1].Expression).Value; - if (expr is string name3) + // Fallback: try to find from syntax + string entityName = entityTypeSyntax.ToString(); + entityTypeName = entityName; + try { - name = name3; + entityTypeNamespace = FindNamespace(entityName, allClasses); } - else if (expr is bool isWrite2) + catch + { + entityTypeNamespace = entityName; + } + } + } + + if (attr.ArgumentList == null || attr.ArgumentList.Arguments.Count == 0) + { + return null; + } + + // Parse arguments (handles positional, named with =, and named with :) + foreach (var arg in attr.ArgumentList.Arguments) + { + if (arg.NameEquals != null) + { + string argName = arg.NameEquals.Name.Identifier.ValueText; + ParseNamedArgument(argName, arg.Expression, semanticModel, ref name, ref isWrite, ref description, ref constraints); + } + else if (arg.NameColon != null) + { + string argName = arg.NameColon.Name.Identifier.ValueText; + ParseNamedArgument(argName, arg.Expression, semanticModel, ref name, ref isWrite, ref description, ref constraints); + } + else + { + // Positional argument - use type-based heuristic + var expr = semanticModel.GetConstantValue(arg.Expression); + if (expr.HasValue) { - isWrite = isWrite2; + if (expr.Value is string s) + name = s; + else if (expr.Value is bool b) + isWrite = b; } } - if (name != null) + } + + if (name == null) + { + return null; + } + + return new EventAttributeInfo + { + Name = name, + IsWrite = isWrite, + Description = description, + Constraints = constraints, + IsGeneric = isGeneric, + EntityTypeName = entityTypeName, + EntityTypeNamespace = entityTypeNamespace + }; + } + + private static void ParseNamedArgument( + string argName, + ExpressionSyntax expression, + SemanticModel semanticModel, + ref string name, + ref bool isWrite, + ref string description, + ref List constraints) + { + // Handle both property names and constructor parameter names (case-insensitive first char) + string normalizedName = argName.Length > 0 + ? char.ToUpperInvariant(argName[0]) + argName.Substring(1) + : argName; + + if (normalizedName == "Name") + { + name = semanticModel.GetConstantValue(expression).Value as string ?? name; + } + else if (normalizedName == "IsWriteName" || normalizedName == "IsWrite") + { + var val = semanticModel.GetConstantValue(expression); + if (val.HasValue && val.Value is bool b) + isWrite = b; + } + else if (normalizedName == "Description") + { + description = semanticModel.GetConstantValue(expression).Value as string; + } + else if (normalizedName == "Constraints") + { + constraints = ParseStringArray(semanticModel, expression); + } + } + + private static List ParseStringArray(SemanticModel semanticModel, ExpressionSyntax expression) + { + var result = new List(); + + InitializerExpressionSyntax initializer = null; + + if (expression is ImplicitArrayCreationExpressionSyntax implicitArray) + { + initializer = implicitArray.Initializer; + } + else if (expression is ArrayCreationExpressionSyntax arrayCreation) + { + initializer = arrayCreation.Initializer; + } + + if (initializer != null) + { + foreach (var elem in initializer.Expressions) { - setAttributes.Add((name, isWrite)); + var val = semanticModel.GetConstantValue(elem); + if (val.HasValue && val.Value is string s) + { + result.Add(s); + } } + return result.Count > 0 ? result : null; } - if (setAttributes.Count > 0) + // Handle collection expression syntax ["a", "b"] + if (expression is CollectionExpressionSyntax collExpr) { - return (setAttributes, className, nsName); + foreach (var elem in collExpr.Elements) + { + if (elem is ExpressionElementSyntax exprElem) + { + var val = semanticModel.GetConstantValue(exprElem.Expression); + if (val.HasValue && val.Value is string s) + { + result.Add(s); + } + } + } + return result.Count > 0 ? result : null; } + return null; } + #endregion + + #region Namespace Helpers + private static string FindNamespace(TypeDeclarationSyntax typeDeclaration) { if (typeDeclaration.Parent is BaseNamespaceDeclarationSyntax nsDeclBase) @@ -269,5 +796,7 @@ private static string FindNamespace(string className, IEnumerable +/// Lightweight description of an Event registered in the catalog +/// +/// The name of the Event +/// Optional description of the Event +/// Optional constraints associated with the Event +public record EventCatalogEntry(string EventName, string? Description, string[]? Constraints); diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogEventDetails.cs b/src/Papst.EventStore/EventCatalog/EventCatalogEventDetails.cs new file mode 100644 index 0000000..9c2f177 --- /dev/null +++ b/src/Papst.EventStore/EventCatalog/EventCatalogEventDetails.cs @@ -0,0 +1,10 @@ +namespace Papst.EventStore.EventCatalog; + +/// +/// Detailed description of an Event including its JSON Schema +/// +/// The name of the Event +/// Optional description of the Event +/// Optional constraints associated with the Event +/// JSON Schema of the Event payload +public record EventCatalogEventDetails(string EventName, string? Description, string[]? Constraints, string JsonSchema); diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs new file mode 100644 index 0000000..790c9bd --- /dev/null +++ b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs @@ -0,0 +1,35 @@ +using System.Linq; + +namespace Papst.EventStore.EventCatalog; + +/// +/// Default implementation of that delegates to registered instances +/// +public sealed class EventCatalogProvider : IEventCatalog +{ + private readonly IEnumerable _registrations; + + public EventCatalogProvider(IEnumerable registrations) + { + _registrations = registrations; + } + + /// + public IReadOnlyList ListEvents(string? name = null, string[]? constraints = null) + { + Type entityType = typeof(TEntity); + + return _registrations + .SelectMany(r => r.GetEntries(entityType, name, constraints)) + .ToList() + .AsReadOnly(); + } + + /// + public EventCatalogEventDetails? GetEventDetails(string eventName) + { + return _registrations + .Select(r => r.GetDetails(eventName)) + .FirstOrDefault(d => d is not null); + } +} diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs new file mode 100644 index 0000000..b6db310 --- /dev/null +++ b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs @@ -0,0 +1,69 @@ +using System.Linq; + +namespace Papst.EventStore.EventCatalog; + +/// +/// In-memory implementation of +/// +public sealed class EventCatalogRegistration : IEventCatalogRegistration +{ + private readonly record struct CatalogItem(string EventName, string? Description, string[]? Constraints, Lazy SchemaJson); + + private readonly Dictionary> _entityEvents = new(); + private readonly Dictionary _eventsByName = new(); + + /// + public void RegisterEvent(string eventName, string? description, string[]? constraints, Lazy schemaJson) + { + Type entityType = typeof(TEntity); + CatalogItem item = new(eventName, description, constraints, schemaJson); + + if (!_entityEvents.TryGetValue(entityType, out List? items)) + { + items = new List(); + _entityEvents[entityType] = items; + } + items.Add(item); + + _eventsByName.TryAdd(eventName, item); + } + + /// + IReadOnlyList IEventCatalogRegistration.GetEntries(Type entityType, string? name, string[]? constraints) + { + if (!_entityEvents.TryGetValue(entityType, out List? items)) + { + return Array.Empty(); + } + + IEnumerable filtered = items; + + if (name is not null) + { + filtered = filtered.Where(i => i.EventName == name); + } + + if (constraints is { Length: > 0 }) + { + filtered = filtered.Where(i => + i.Constraints is not null && + i.Constraints.Any(c => constraints.Contains(c))); + } + + return filtered + .Select(i => new EventCatalogEntry(i.EventName, i.Description, i.Constraints)) + .ToList() + .AsReadOnly(); + } + + /// + EventCatalogEventDetails? IEventCatalogRegistration.GetDetails(string eventName) + { + if (!_eventsByName.TryGetValue(eventName, out CatalogItem item)) + { + return null; + } + + return new EventCatalogEventDetails(item.EventName, item.Description, item.Constraints, item.SchemaJson.Value); + } +} diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalog.cs b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs new file mode 100644 index 0000000..e771999 --- /dev/null +++ b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs @@ -0,0 +1,23 @@ +namespace Papst.EventStore.EventCatalog; + +/// +/// Provides read access to the Event Catalog, allowing queries for registered events per entity +/// +public interface IEventCatalog +{ + /// + /// List events registered for entity , optionally filtered by name and/or constraints + /// + /// The Entity to list events for + /// Optional event name filter (exact match) + /// Optional constraints filter (events matching any of the given constraints) + /// A list of matching instances + IReadOnlyList ListEvents(string? name = null, string[]? constraints = null); + + /// + /// Get detailed information (including JSON Schema) for a specific event by name + /// + /// The event name to look up + /// The or null if the event is not registered + EventCatalogEventDetails? GetEventDetails(string eventName); +} diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs new file mode 100644 index 0000000..696ff72 --- /dev/null +++ b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs @@ -0,0 +1,27 @@ +namespace Papst.EventStore.EventCatalog; + +/// +/// Registration interface used by generated code to populate the Event Catalog +/// +public interface IEventCatalogRegistration +{ + /// + /// Register an event for a specific entity type + /// + /// The entity type this event is associated with + /// The name of the event + /// Optional description + /// Optional constraints + /// Lazily evaluated JSON schema string + void RegisterEvent(string eventName, string? description, string[]? constraints, Lazy schemaJson); + + /// + /// Get catalog entries for a given entity type, optionally filtered + /// + internal IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints); + + /// + /// Get detailed event information by name + /// + internal EventCatalogEventDetails? GetDetails(string eventName); +} diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs index e172d49..98c2c3c 100644 --- a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs +++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs @@ -471,6 +471,162 @@ public static void Main(string[] args) diagnostics.First().Id.Should().Be("EVTSRC0002"); } + [Fact] + public void TestGenericEventNameAttributeGeneratesCatalog() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + public class UserEntity {} + [EventName(""UserCreated"", Description = ""A user was created"", Constraints = new[] { ""Create"" })] + public record UserCreatedEvent(string Name); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("AddCodeGeneratedEventCatalog"); + source.Should().Contain("catalog.RegisterEvent("); + source.Should().Contain("\"UserCreated\""); + source.Should().Contain("\"A user was created\""); + source.Should().Contain("new string[] { \"Create\" }"); + source.Should().Contain("\"\"name\"\""); + } + + [Fact] + public void TestAggregatorBasedCatalogGeneration() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + [EventName(""FooEvent"")] + public record FooEvent(int Value); + public record FooEntity(); + public class FooAggregator : EventAggregatorBase + { + public override Task ApplyAsync(FooEvent evt, FooEntity entity, IAggregatorStreamContext ctx) + { throw new NotImplementedException(); } + } +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("AddCodeGeneratedEventCatalog"); + source.Should().Contain("catalog.RegisterEvent("); + source.Should().Contain("\"FooEvent\""); + source.Should().Contain("\"\"value\"\""); + source.Should().Contain("\"\"integer\"\""); + } + + [Fact] + public void TestCatalogJsonSchemaGeneration() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + public class MyEntity {} + public enum Status { Active, Inactive } + [EventName(""ComplexEvent"")] + public record ComplexEvent(string Name, int Count, bool IsActive, Status Status, string[] Tags); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("\"\"name\"\":{\"\"type\"\":\"\"string\"\"}"); + source.Should().Contain("\"\"count\"\":{\"\"type\"\":\"\"integer\"\"}"); + source.Should().Contain("\"\"isActive\"\":{\"\"type\"\":\"\"boolean\"\"}"); + source.Should().Contain("\"\"status\"\":{\"\"type\"\":\"\"string\"\",\"\"enum\"\":[\"\"Active\"\",\"\"Inactive\"\"]}"); + source.Should().Contain("\"\"tags\"\":{\"\"type\"\":\"\"array\"\",\"\"items\"\":{\"\"type\"\":\"\"string\"\"}}"); + } + + [Fact] + public void TestDescriptionAndConstraintsOnNonGenericAttribute() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + [EventName(""BarEvent"", Description = ""Bar happened"", Constraints = new[] { ""Read"", ""Write"" })] + public record BarEvent(string Data); + public record BarEntity(); + public class BarAggregator : EventAggregatorBase + { + public override Task ApplyAsync(BarEvent evt, BarEntity entity, IAggregatorStreamContext ctx) + { throw new NotImplementedException(); } + } +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("catalog.RegisterEvent(\"BarEvent\", \"Bar happened\", new string[] { \"Read\", \"Write\" },"); + } + + [Fact] + public void TestNoCatalogWithoutEntityAssociation() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + [EventName(""OrphanEvent"")] + public record OrphanEvent(string Value); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().NotContain("AddCodeGeneratedEventCatalog"); + source.Should().Contain("AddCodeGeneratedEvents"); + } + + [Fact] + public void TestCatalogWithNullDescriptionAndConstraints() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + public class MyEntity {} + [EventName(""SimpleEvent"")] + public record SimpleEvent(string Value); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("catalog.RegisterEvent(\"SimpleEvent\", null, null,"); + } + private Compilation CreateCompilation(string source) { return CSharpCompilation.Create("compilation", diff --git a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs new file mode 100644 index 0000000..d59439f --- /dev/null +++ b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Papst.EventStore.EventCatalog; +using Xunit; + +namespace Papst.EventStore.Tests.EventCatalog; + +public class EventCatalogProviderTests +{ + private class TestEntity { } + private class OtherEntity { } + + private static (EventCatalogRegistration registration, EventCatalogProvider provider) CreateCatalog() + { + var registration = new EventCatalogRegistration(); + var provider = new EventCatalogProvider(new[] { registration }); + return (registration, provider); + } + + // --- EventCatalogRegistration tests (via provider) --- + + [Fact] + public void RegisterEvent_ShouldStoreEventForEntity() + { + var (registration, provider) = CreateCatalog(); + + registration.RegisterEvent("TestEvent", "A test event", null, new Lazy(() => "{}")); + + var entries = provider.ListEvents(); + + entries.Should().ContainSingle() + .Which.EventName.Should().Be("TestEvent"); + } + + [Fact] + public void GetEntries_FilterByName_ReturnsMatching() + { + var (registration, provider) = CreateCatalog(); + + registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}")); + registration.RegisterEvent("EventB", null, null, new Lazy(() => "{}")); + + var entries = provider.ListEvents(name: "EventA"); + + entries.Should().ContainSingle() + .Which.EventName.Should().Be("EventA"); + } + + [Fact] + public void GetEntries_FilterByConstraints_ReturnsMatching() + { + var (registration, provider) = CreateCatalog(); + + registration.RegisterEvent("EventX", null, new[] { "admin" }, new Lazy(() => "{}")); + registration.RegisterEvent("EventY", null, new[] { "user" }, new Lazy(() => "{}")); + + var entries = provider.ListEvents(constraints: new[] { "admin" }); + + entries.Should().ContainSingle() + .Which.EventName.Should().Be("EventX"); + } + + [Fact] + public void GetEntries_FilterByNameAndConstraints_ReturnsMatching() + { + var (registration, provider) = CreateCatalog(); + + registration.RegisterEvent("EventA", null, new[] { "admin" }, new Lazy(() => "{}")); + registration.RegisterEvent("EventA", null, new[] { "user" }, new Lazy(() => "{}")); + registration.RegisterEvent("EventB", null, new[] { "admin" }, new Lazy(() => "{}")); + + var entries = provider.ListEvents(name: "EventA", constraints: new[] { "admin" }); + + entries.Should().ContainSingle() + .Which.EventName.Should().Be("EventA"); + } + + [Fact] + public void GetEntries_ForUnknownEntity_ReturnsEmpty() + { + var (registration, provider) = CreateCatalog(); + + registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}")); + + var entries = provider.ListEvents(); + + entries.Should().BeEmpty(); + } + + [Fact] + public void GetDetails_ReturnsSchemaAndDescription() + { + var (registration, provider) = CreateCatalog(); + const string schema = """{"type":"object","properties":{"id":{"type":"string"}}}"""; + + registration.RegisterEvent("DetailedEvent", "Has a schema", new[] { "v1" }, new Lazy(() => schema)); + + var details = provider.GetEventDetails("DetailedEvent"); + + details.Should().NotBeNull(); + details!.EventName.Should().Be("DetailedEvent"); + details.Description.Should().Be("Has a schema"); + details.Constraints.Should().BeEquivalentTo(new[] { "v1" }); + details.JsonSchema.Should().Be(schema); + } + + [Fact] + public void GetDetails_UnknownEvent_ReturnsNull() + { + var (_, provider) = CreateCatalog(); + + var details = provider.GetEventDetails("NonExistent"); + + details.Should().BeNull(); + } + + [Fact] + public void GetDetails_LazySchemaEvaluatedOnAccess() + { + var (registration, provider) = CreateCatalog(); + bool evaluated = false; + var lazySchema = new Lazy(() => + { + evaluated = true; + return """{"type":"object"}"""; + }); + + registration.RegisterEvent("LazyEvent", null, null, lazySchema); + + evaluated.Should().BeFalse("schema should not be evaluated at registration time"); + + var details = provider.GetEventDetails("LazyEvent"); + + evaluated.Should().BeTrue("schema should be evaluated when details are requested"); + details!.JsonSchema.Should().Be("""{"type":"object"}"""); + } + + // --- EventCatalogProvider delegation tests --- + + [Fact] + public void ListEvents_DelegatesToRegistrations() + { + var registration = new EventCatalogRegistration(); + registration.RegisterEvent("Evt1", "desc", null, new Lazy(() => "{}")); + var provider = new EventCatalogProvider(new[] { registration }); + + var entries = provider.ListEvents(); + + entries.Should().ContainSingle() + .Which.Should().BeEquivalentTo(new EventCatalogEntry("Evt1", "desc", null)); + } + + [Fact] + public void GetEventDetails_DelegatesToRegistrations() + { + var registration = new EventCatalogRegistration(); + registration.RegisterEvent("Evt2", "desc2", new[] { "c1" }, new Lazy(() => """{"schema":true}""")); + var provider = new EventCatalogProvider(new[] { registration }); + + var details = provider.GetEventDetails("Evt2"); + + details.Should().NotBeNull(); + details.Should().BeEquivalentTo(new EventCatalogEventDetails("Evt2", "desc2", new[] { "c1" }, """{"schema":true}""")); + } + + [Fact] + public void ListEvents_CombinesMultipleRegistrations() + { + var reg1 = new EventCatalogRegistration(); + reg1.RegisterEvent("FromReg1", null, null, new Lazy(() => "{}")); + + var reg2 = new EventCatalogRegistration(); + reg2.RegisterEvent("FromReg2", null, null, new Lazy(() => "{}")); + + var provider = new EventCatalogProvider(new IEventCatalogRegistration[] { reg1, reg2 }); + + var entries = provider.ListEvents(); + + entries.Should().HaveCount(2); + entries.Select(e => e.EventName).Should().BeEquivalentTo("FromReg1", "FromReg2"); + } +} From 1507443281aeed3bce9bef947c2a041b90027c07 Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Sat, 11 Apr 2026 16:46:57 +0200 Subject: [PATCH 2/5] Add Event Catalog features and JSON Schema validation - Introduced Event Catalog for managing events associated with entity types, including metadata and JSON Schema generation. - Updated `ListEvents` and `GetEventDetails` methods in `EventCatalogProvider` to return `ValueTask` for better async performance. - Enhanced `EventCatalogRegistration` to support entity-scoped event details retrieval. - Added tests for event catalog generation, JSON Schema validation, and entity-scoped event details. - Updated sample application to demonstrate new Event Catalog features and JSON Schema validation. - Replaced `FluentAssertions` with `JsonSchema.Net` for JSON Schema handling. --- README.md | 54 ++++++ samples/SampleEventCatalog/Program.cs | 161 +++++++++++++++-- .../SampleEventCatalog.csproj | 1 + .../EventCatalog/EventCatalogProvider.cs | 24 ++- .../EventCatalog/EventCatalogRegistration.cs | 28 ++- .../EventCatalog/IEventCatalog.cs | 16 +- .../EventCatalog/IEventCatalogRegistration.cs | 7 +- .../CodeGeneratorCatalogTests.cs | 166 +++++++++++++++++ .../CodeGeneratorTestBase.cs | 16 ++ .../CodeGeneratorTests.cs | 167 +----------------- .../EventCatalog/EventCatalogProviderTests.cs | 93 +++++++--- 11 files changed, 523 insertions(+), 210 deletions(-) create mode 100644 tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs create mode 100644 tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTestBase.cs diff --git a/README.md b/README.md index 4dcd047..b833988 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,60 @@ Please refer to the documentation in the relevant implementation sources: * [Azure Cosmos](./src/Papst.EventStore.AzureCosmos/README.md) * [Entity Framework Core](./src/Papst.EventStore.EntityFrameworkCore/README.md) +## Event Catalog + +The **Event Catalog** provides a queryable registry of all events associated with a given entity type, including metadata (description, constraints) and a compile-time generated JSON Schema. This is useful for documentation, API discovery, and runtime introspection. + +### Registering Events for the Catalog + +Use the generic `EventNameAttribute` to associate an event with an entity: + +```csharp +[EventName("UserCreated", Description = "Raised when a new user is created", Constraints = new[] { "Create" })] +public record UserCreatedEvent(string Name, string Email); + +[EventName("UserRenamed", Description = "Raised when a user changes their name", Constraints = new[] { "Update" })] +public record UserRenamedEvent(string NewName); +``` + +Events can also be discovered automatically via aggregator registrations (`EventAggregatorBase` / `IEventAggregator`). + +The same event name may exist for different entity types — the catalog tracks them independently per entity. + +### Code Generation + +When the `Papst.EventStore.CodeGeneration` package is referenced, the source generator automatically emits an `AddCodeGeneratedEventCatalog()` extension method alongside the existing `AddCodeGeneratedEvents()`: + +```csharp +var services = new ServiceCollection(); +services.AddCodeGeneratedEvents(); +services.AddCodeGeneratedEventCatalog(); +``` + +### Querying the Catalog + +Resolve `IEventCatalog` from DI and use the async API: + +```csharp +var catalog = serviceProvider.GetRequiredService(); + +// List all events for an entity +IReadOnlyList events = await catalog.ListEvents(); + +// Filter by name and/or constraints +IReadOnlyList filtered = await catalog.ListEvents( + name: "UserCreated", + constraints: new[] { "Create" } +); + +// Get event details including JSON Schema (global lookup) +EventCatalogEventDetails? details = await catalog.GetEventDetails("UserCreated"); + +// Get event details scoped to a specific entity (for duplicate event names across entities) +EventCatalogEventDetails? scoped = await catalog.GetEventDetails("UserCreated"); +``` + +A full working sample is available at [`samples/SampleEventCatalog/`](./samples/SampleEventCatalog/). # Changelog diff --git a/samples/SampleEventCatalog/Program.cs b/samples/SampleEventCatalog/Program.cs index fd8a82d..dac5c1e 100644 --- a/samples/SampleEventCatalog/Program.cs +++ b/samples/SampleEventCatalog/Program.cs @@ -1,11 +1,14 @@ +using Json.Schema; using Microsoft.Extensions.DependencyInjection; using Papst.EventStore.EventCatalog; +using System.Text.Json; +using System.Text.Json.Nodes; namespace SampleEventCatalog; public class Program { - public static void Main(string[] args) + public static async Task Main(string[] args) { var services = new ServiceCollection(); @@ -21,7 +24,7 @@ public static void Main(string[] args) // List all events for User entity Console.WriteLine("--- Events for User entity ---"); - var userEvents = catalog.ListEvents(); + var userEvents = await catalog.ListEvents(); foreach (var entry in userEvents) { Console.WriteLine($" Event: {entry.EventName}"); @@ -32,7 +35,7 @@ public static void Main(string[] args) // List all events for Order entity Console.WriteLine("--- Events for Order entity ---"); - var orderEvents = catalog.ListEvents(); + var orderEvents = await catalog.ListEvents(); foreach (var entry in orderEvents) { Console.WriteLine($" Event: {entry.EventName}"); @@ -43,7 +46,7 @@ public static void Main(string[] args) // Filter events by constraint Console.WriteLine("--- User events with 'Update' constraint ---"); - var updateEvents = catalog.ListEvents(constraints: new[] { "Update" }); + var updateEvents = await catalog.ListEvents(constraints: new[] { "Update" }); foreach (var entry in updateEvents) { Console.WriteLine($" Event: {entry.EventName} ({entry.Description})"); @@ -52,16 +55,16 @@ public static void Main(string[] args) // Filter events by name Console.WriteLine("--- Looking up 'UserCreated' event ---"); - var filtered = catalog.ListEvents(name: "UserCreated"); + var filtered = await catalog.ListEvents(name: "UserCreated"); foreach (var entry in filtered) { Console.WriteLine($" Found: {entry.EventName} - {entry.Description}"); } Console.WriteLine(); - // Get event details with JSON schema + // Get event details with JSON schema (global lookup) Console.WriteLine("--- Event Details with JSON Schema ---"); - var details = catalog.GetEventDetails("OrderPlaced"); + var details = await catalog.GetEventDetails("OrderPlaced"); if (details != null) { Console.WriteLine($" Event: {details.EventName}"); @@ -71,11 +74,149 @@ public static void Main(string[] args) } Console.WriteLine(); - Console.WriteLine("--- JSON Schema for UserCreatedEvent ---"); - var userCreatedDetails = catalog.GetEventDetails("UserCreated"); + // Get event details scoped to entity + Console.WriteLine("--- Entity-scoped Event Details for User/UserCreated ---"); + var userCreatedDetails = await catalog.GetEventDetails("UserCreated"); if (userCreatedDetails != null) { - Console.WriteLine($" {userCreatedDetails.JsonSchema}"); + Console.WriteLine($" Event: {userCreatedDetails.EventName}"); + Console.WriteLine($" Description: {userCreatedDetails.Description}"); + Console.WriteLine($" JSON Schema: {userCreatedDetails.JsonSchema}"); + } + + Console.WriteLine(); + + // --- JSON Schema: build a JSON object and validate it --- + await DemonstrateSchemaValidationAsync(catalog); + } + + private static async Task DemonstrateSchemaValidationAsync(IEventCatalog catalog) + { + Console.WriteLine("=== JSON Schema Validation Demo ==="); + Console.WriteLine(); + + var details = await catalog.GetEventDetails("UserCreated"); + if (details is null) + { + Console.WriteLine(" Event 'UserCreated' not found in catalog."); + return; + } + + Console.WriteLine($" Schema for '{details.EventName}':"); + Console.WriteLine($" {FormatJson(details.JsonSchema)}"); + Console.WriteLine(); + + var schema = JsonSchema.FromText(details.JsonSchema); + + // --- Valid object --- + var validEvent = new JsonObject + { + ["name"] = "Alice", + ["email"] = "alice@example.com" + }; + + var validResult = schema.Evaluate(ToJsonElement(validEvent), new EvaluationOptions { OutputFormat = OutputFormat.List }); + + Console.WriteLine(" Valid event object:"); + Console.WriteLine($" {validEvent.ToJsonString()}"); + Console.WriteLine($" Validation: {(validResult.IsValid ? "✓ PASSED" : "✗ FAILED")}"); + Console.WriteLine(); + + // --- Invalid: wrong type for 'name' --- + var invalidTypeEvent = new JsonObject + { + ["name"] = 42, + ["email"] = "bob@example.com" + }; + + var invalidTypeResult = schema.Evaluate(ToJsonElement(invalidTypeEvent), new EvaluationOptions { OutputFormat = OutputFormat.List }); + + Console.WriteLine(" Invalid event object (wrong type for 'name'):"); + Console.WriteLine($" {invalidTypeEvent.ToJsonString()}"); + Console.WriteLine($" Validation: {(invalidTypeResult.IsValid ? "✓ PASSED" : "✗ FAILED")}"); + if (!invalidTypeResult.IsValid) + { + foreach (var error in (invalidTypeResult.Details ?? []).Where(d => !d.IsValid && d.Errors is not null)) + { + foreach (var (key, message) in error.Errors!) + { + Console.WriteLine($" - [{error.InstanceLocation}] {key}: {message}"); + } + } + } + Console.WriteLine(); + + // --- Invalid: extra strictness demo using OrderPlaced --- + var orderDetails = await catalog.GetEventDetails("OrderPlaced"); + if (orderDetails is null) return; + + Console.WriteLine($" Schema for 'OrderPlaced':"); + Console.WriteLine($" {FormatJson(orderDetails.JsonSchema)}"); + Console.WriteLine(); + + var orderSchema = JsonSchema.FromText(orderDetails.JsonSchema); + + // Valid OrderPlaced object + var validOrder = new JsonObject + { + ["userId"] = Guid.NewGuid().ToString(), + ["items"] = new JsonArray + { + new JsonObject + { + ["productName"] = "Widget", + ["quantity"] = 2, + ["price"] = 9.99 + } + }, + ["total"] = 19.98 + }; + + var validOrderResult = orderSchema.Evaluate(ToJsonElement(validOrder), new EvaluationOptions { OutputFormat = OutputFormat.List }); + + Console.WriteLine(" Valid OrderPlaced object:"); + Console.WriteLine($" {validOrder.ToJsonString()}"); + Console.WriteLine($" Validation: {(validOrderResult.IsValid ? "✓ PASSED" : "✗ FAILED")}"); + Console.WriteLine(); + + // Invalid: total is a string instead of number + var invalidOrder = new JsonObject + { + ["userId"] = Guid.NewGuid().ToString(), + ["items"] = new JsonArray(), + ["total"] = "not-a-number" + }; + + var invalidOrderResult = orderSchema.Evaluate(ToJsonElement(invalidOrder), new EvaluationOptions { OutputFormat = OutputFormat.List }); + + Console.WriteLine(" Invalid OrderPlaced object ('total' is a string):"); + Console.WriteLine($" {invalidOrder.ToJsonString()}"); + Console.WriteLine($" Validation: {(invalidOrderResult.IsValid ? "✓ PASSED" : "✗ FAILED")}"); + if (!invalidOrderResult.IsValid) + { + foreach (var error in (invalidOrderResult.Details ?? []).Where(d => !d.IsValid && d.Errors is not null)) + { + foreach (var (key, message) in error.Errors!) + { + Console.WriteLine($" - [{error.InstanceLocation}] {key}: {message}"); + } + } + } + } + + private static JsonElement ToJsonElement(JsonNode node) + => JsonSerializer.Deserialize(node.ToJsonString()); + + private static string FormatJson(string json) + { + try + { + var doc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return json; } } } diff --git a/samples/SampleEventCatalog/SampleEventCatalog.csproj b/samples/SampleEventCatalog/SampleEventCatalog.csproj index 54f60c5..13d1db0 100644 --- a/samples/SampleEventCatalog/SampleEventCatalog.csproj +++ b/samples/SampleEventCatalog/SampleEventCatalog.csproj @@ -13,5 +13,6 @@ + diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs index 790c9bd..3c3bf2e 100644 --- a/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs +++ b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs @@ -15,21 +15,37 @@ public EventCatalogProvider(IEnumerable registrations } /// - public IReadOnlyList ListEvents(string? name = null, string[]? constraints = null) + public ValueTask> ListEvents(string? name = null, string[]? constraints = null) { Type entityType = typeof(TEntity); - return _registrations + IReadOnlyList result = _registrations .SelectMany(r => r.GetEntries(entityType, name, constraints)) .ToList() .AsReadOnly(); + + return new ValueTask>(result); } /// - public EventCatalogEventDetails? GetEventDetails(string eventName) + public ValueTask GetEventDetails(string eventName) { - return _registrations + EventCatalogEventDetails? result = _registrations .Select(r => r.GetDetails(eventName)) .FirstOrDefault(d => d is not null); + + return new ValueTask(result); + } + + /// + public ValueTask GetEventDetails(string eventName) + { + Type entityType = typeof(TEntity); + + EventCatalogEventDetails? result = _registrations + .Select(r => r.GetDetails(entityType, eventName)) + .FirstOrDefault(d => d is not null); + + return new ValueTask(result); } } diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs index b6db310..245a671 100644 --- a/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs +++ b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs @@ -10,7 +10,6 @@ public sealed class EventCatalogRegistration : IEventCatalogRegistration private readonly record struct CatalogItem(string EventName, string? Description, string[]? Constraints, Lazy SchemaJson); private readonly Dictionary> _entityEvents = new(); - private readonly Dictionary _eventsByName = new(); /// public void RegisterEvent(string eventName, string? description, string[]? constraints, Lazy schemaJson) @@ -24,8 +23,6 @@ public void RegisterEvent(string eventName, string? description, string _entityEvents[entityType] = items; } items.Add(item); - - _eventsByName.TryAdd(eventName, item); } /// @@ -59,11 +56,34 @@ i.Constraints is not null && /// EventCatalogEventDetails? IEventCatalogRegistration.GetDetails(string eventName) { - if (!_eventsByName.TryGetValue(eventName, out CatalogItem item)) + foreach (List items in _entityEvents.Values) + { + CatalogItem? found = items.Cast().FirstOrDefault(i => i!.Value.EventName == eventName); + if (found.HasValue) + { + CatalogItem item = found.Value; + return new EventCatalogEventDetails(item.EventName, item.Description, item.Constraints, item.SchemaJson.Value); + } + } + + return null; + } + + /// + EventCatalogEventDetails? IEventCatalogRegistration.GetDetails(Type entityType, string eventName) + { + if (!_entityEvents.TryGetValue(entityType, out List? items)) + { + return null; + } + + CatalogItem? found = items.Cast().FirstOrDefault(i => i!.Value.EventName == eventName); + if (!found.HasValue) { return null; } + CatalogItem item = found.Value; return new EventCatalogEventDetails(item.EventName, item.Description, item.Constraints, item.SchemaJson.Value); } } diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalog.cs b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs index e771999..7354830 100644 --- a/src/Papst.EventStore/EventCatalog/IEventCatalog.cs +++ b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs @@ -12,12 +12,22 @@ public interface IEventCatalog /// Optional event name filter (exact match) /// Optional constraints filter (events matching any of the given constraints) /// A list of matching instances - IReadOnlyList ListEvents(string? name = null, string[]? constraints = null); + ValueTask> ListEvents(string? name = null, string[]? constraints = null); /// - /// Get detailed information (including JSON Schema) for a specific event by name + /// Get detailed information (including JSON Schema) for a specific event by name. + /// If the same event name exists for multiple entities, the first match is returned. /// /// The event name to look up /// The or null if the event is not registered - EventCatalogEventDetails? GetEventDetails(string eventName); + ValueTask GetEventDetails(string eventName); + + /// + /// Get detailed information (including JSON Schema) for a specific event by name, scoped to entity . + /// Use this overload when the same event name may exist for different entities. + /// + /// The entity type to scope the lookup to + /// The event name to look up + /// The or null if the event is not registered for this entity + ValueTask GetEventDetails(string eventName); } diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs index 696ff72..668eb12 100644 --- a/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs +++ b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs @@ -21,7 +21,12 @@ public interface IEventCatalogRegistration internal IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints); /// - /// Get detailed event information by name + /// Get detailed event information by name (first match across all entities) /// internal EventCatalogEventDetails? GetDetails(string eventName); + + /// + /// Get detailed event information by name, scoped to a specific entity type + /// + internal EventCatalogEventDetails? GetDetails(Type entityType, string eventName); } diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs new file mode 100644 index 0000000..79435fa --- /dev/null +++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs @@ -0,0 +1,166 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Linq; +using Xunit; + +namespace Papst.EventStore.CodeGeneration.Tests; + +public class CodeGeneratorCatalogTests : CodeGeneratorTestBase +{ + [Fact] + public void TestGenericEventNameAttributeGeneratesCatalog() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + public class UserEntity {} + [EventName(""UserCreated"", Description = ""A user was created"", Constraints = new[] { ""Create"" })] + public record UserCreatedEvent(string Name); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("AddCodeGeneratedEventCatalog"); + source.Should().Contain("catalog.RegisterEvent("); + source.Should().Contain("\"UserCreated\""); + source.Should().Contain("\"A user was created\""); + source.Should().Contain("new string[] { \"Create\" }"); + source.Should().Contain("\"\"name\"\""); + } + + [Fact] + public void TestAggregatorBasedCatalogGeneration() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + [EventName(""FooEvent"")] + public record FooEvent(int Value); + public record FooEntity(); + public class FooAggregator : EventAggregatorBase + { + public override Task ApplyAsync(FooEvent evt, FooEntity entity, IAggregatorStreamContext ctx) + { throw new NotImplementedException(); } + } +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("AddCodeGeneratedEventCatalog"); + source.Should().Contain("catalog.RegisterEvent("); + source.Should().Contain("\"FooEvent\""); + source.Should().Contain("\"\"value\"\""); + source.Should().Contain("\"\"integer\"\""); + } + + [Fact] + public void TestCatalogJsonSchemaGeneration() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + public class MyEntity {} + public enum Status { Active, Inactive } + [EventName(""ComplexEvent"")] + public record ComplexEvent(string Name, int Count, bool IsActive, Status Status, string[] Tags); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("\"\"name\"\":{\"\"type\"\":\"\"string\"\"}"); + source.Should().Contain("\"\"count\"\":{\"\"type\"\":\"\"integer\"\"}"); + source.Should().Contain("\"\"isActive\"\":{\"\"type\"\":\"\"boolean\"\"}"); + source.Should().Contain("\"\"status\"\":{\"\"type\"\":\"\"string\"\",\"\"enum\"\":[\"\"Active\"\",\"\"Inactive\"\"]}"); + source.Should().Contain("\"\"tags\"\":{\"\"type\"\":\"\"array\"\",\"\"items\"\":{\"\"type\"\":\"\"string\"\"}}"); + } + + [Fact] + public void TestDescriptionAndConstraintsOnNonGenericAttribute() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + [EventName(""BarEvent"", Description = ""Bar happened"", Constraints = new[] { ""Read"", ""Write"" })] + public record BarEvent(string Data); + public record BarEntity(); + public class BarAggregator : EventAggregatorBase + { + public override Task ApplyAsync(BarEvent evt, BarEntity entity, IAggregatorStreamContext ctx) + { throw new NotImplementedException(); } + } +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("catalog.RegisterEvent(\"BarEvent\", \"Bar happened\", new string[] { \"Read\", \"Write\" },"); + } + + [Fact] + public void TestNoCatalogWithoutEntityAssociation() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + [EventName(""OrphanEvent"")] + public record OrphanEvent(string Value); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().NotContain("AddCodeGeneratedEventCatalog"); + source.Should().Contain("AddCodeGeneratedEvents"); + } + + [Fact] + public void TestCatalogWithNullDescriptionAndConstraints() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + public class MyEntity {} + [EventName(""SimpleEvent"")] + public record SimpleEvent(string Value); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("catalog.RegisterEvent(\"SimpleEvent\", null, null,"); + } +} diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTestBase.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTestBase.cs new file mode 100644 index 0000000..0bc3622 --- /dev/null +++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTestBase.cs @@ -0,0 +1,16 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Reflection; + +namespace Papst.EventStore.CodeGeneration.Tests; + +public abstract class CodeGeneratorTestBase +{ + protected static Compilation CreateCompilation(string source) + { + return CSharpCompilation.Create("compilation", + new[] { CSharpSyntaxTree.ParseText(source) }, + new[] { MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + } +} diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs index 98c2c3c..465cb17 100644 --- a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs +++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs @@ -8,7 +8,7 @@ namespace Papst.EventStore.CodeGeneration.Tests; -public class CodeGeneratorTests +public class CodeGeneratorTests : CodeGeneratorTestBase { [Fact] public void TestFindsNamespaceWithEntryPoint() @@ -470,169 +470,4 @@ public static void Main(string[] args) diagnostics.Should().HaveCount(1); diagnostics.First().Id.Should().Be("EVTSRC0002"); } - - [Fact] - public void TestGenericEventNameAttributeGeneratesCatalog() - { - var generator = new EventRegistrationIncrementalCodeGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - Compilation inputCompilation = CreateCompilation(@" -namespace MyCode -{ - public class Program { public static void Main(string[] args) {} } - public class UserEntity {} - [EventName(""UserCreated"", Description = ""A user was created"", Constraints = new[] { ""Create"" })] - public record UserCreatedEvent(string Name); -} -"); - driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); - var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("AddCodeGeneratedEventCatalog"); - source.Should().Contain("catalog.RegisterEvent("); - source.Should().Contain("\"UserCreated\""); - source.Should().Contain("\"A user was created\""); - source.Should().Contain("new string[] { \"Create\" }"); - source.Should().Contain("\"\"name\"\""); - } - - [Fact] - public void TestAggregatorBasedCatalogGeneration() - { - var generator = new EventRegistrationIncrementalCodeGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - Compilation inputCompilation = CreateCompilation(@" -namespace MyCode -{ - public class Program { public static void Main(string[] args) {} } - [EventName(""FooEvent"")] - public record FooEvent(int Value); - public record FooEntity(); - public class FooAggregator : EventAggregatorBase - { - public override Task ApplyAsync(FooEvent evt, FooEntity entity, IAggregatorStreamContext ctx) - { throw new NotImplementedException(); } - } -} -"); - driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); - var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("AddCodeGeneratedEventCatalog"); - source.Should().Contain("catalog.RegisterEvent("); - source.Should().Contain("\"FooEvent\""); - source.Should().Contain("\"\"value\"\""); - source.Should().Contain("\"\"integer\"\""); - } - - [Fact] - public void TestCatalogJsonSchemaGeneration() - { - var generator = new EventRegistrationIncrementalCodeGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - Compilation inputCompilation = CreateCompilation(@" -namespace MyCode -{ - public class Program { public static void Main(string[] args) {} } - public class MyEntity {} - public enum Status { Active, Inactive } - [EventName(""ComplexEvent"")] - public record ComplexEvent(string Name, int Count, bool IsActive, Status Status, string[] Tags); -} -"); - driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); - var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("\"\"name\"\":{\"\"type\"\":\"\"string\"\"}"); - source.Should().Contain("\"\"count\"\":{\"\"type\"\":\"\"integer\"\"}"); - source.Should().Contain("\"\"isActive\"\":{\"\"type\"\":\"\"boolean\"\"}"); - source.Should().Contain("\"\"status\"\":{\"\"type\"\":\"\"string\"\",\"\"enum\"\":[\"\"Active\"\",\"\"Inactive\"\"]}"); - source.Should().Contain("\"\"tags\"\":{\"\"type\"\":\"\"array\"\",\"\"items\"\":{\"\"type\"\":\"\"string\"\"}}"); - } - - [Fact] - public void TestDescriptionAndConstraintsOnNonGenericAttribute() - { - var generator = new EventRegistrationIncrementalCodeGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - Compilation inputCompilation = CreateCompilation(@" -namespace MyCode -{ - public class Program { public static void Main(string[] args) {} } - [EventName(""BarEvent"", Description = ""Bar happened"", Constraints = new[] { ""Read"", ""Write"" })] - public record BarEvent(string Data); - public record BarEntity(); - public class BarAggregator : EventAggregatorBase - { - public override Task ApplyAsync(BarEvent evt, BarEntity entity, IAggregatorStreamContext ctx) - { throw new NotImplementedException(); } - } -} -"); - driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); - var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("catalog.RegisterEvent(\"BarEvent\", \"Bar happened\", new string[] { \"Read\", \"Write\" },"); - } - - [Fact] - public void TestNoCatalogWithoutEntityAssociation() - { - var generator = new EventRegistrationIncrementalCodeGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - Compilation inputCompilation = CreateCompilation(@" -namespace MyCode -{ - public class Program { public static void Main(string[] args) {} } - [EventName(""OrphanEvent"")] - public record OrphanEvent(string Value); -} -"); - driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); - var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().NotContain("AddCodeGeneratedEventCatalog"); - source.Should().Contain("AddCodeGeneratedEvents"); - } - - [Fact] - public void TestCatalogWithNullDescriptionAndConstraints() - { - var generator = new EventRegistrationIncrementalCodeGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - Compilation inputCompilation = CreateCompilation(@" -namespace MyCode -{ - public class Program { public static void Main(string[] args) {} } - public class MyEntity {} - [EventName(""SimpleEvent"")] - public record SimpleEvent(string Value); -} -"); - driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); - var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("catalog.RegisterEvent(\"SimpleEvent\", null, null,"); - } - - private Compilation CreateCompilation(string source) - { - return CSharpCompilation.Create("compilation", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] { MetadataReference.CreateFromFile(typeof(System.Reflection.Binder).GetTypeInfo().Assembly.Location) }, - new CSharpCompilationOptions(OutputKind.ConsoleApplication)); - } - } diff --git a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs index d59439f..b276d3a 100644 --- a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs +++ b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using FluentAssertions; using Papst.EventStore.EventCatalog; using Xunit; @@ -22,48 +23,48 @@ private static (EventCatalogRegistration registration, EventCatalogProvider prov // --- EventCatalogRegistration tests (via provider) --- [Fact] - public void RegisterEvent_ShouldStoreEventForEntity() + public async Task RegisterEvent_ShouldStoreEventForEntity() { var (registration, provider) = CreateCatalog(); registration.RegisterEvent("TestEvent", "A test event", null, new Lazy(() => "{}")); - var entries = provider.ListEvents(); + var entries = await provider.ListEvents(); entries.Should().ContainSingle() .Which.EventName.Should().Be("TestEvent"); } [Fact] - public void GetEntries_FilterByName_ReturnsMatching() + public async Task GetEntries_FilterByName_ReturnsMatching() { var (registration, provider) = CreateCatalog(); registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}")); registration.RegisterEvent("EventB", null, null, new Lazy(() => "{}")); - var entries = provider.ListEvents(name: "EventA"); + var entries = await provider.ListEvents(name: "EventA"); entries.Should().ContainSingle() .Which.EventName.Should().Be("EventA"); } [Fact] - public void GetEntries_FilterByConstraints_ReturnsMatching() + public async Task GetEntries_FilterByConstraints_ReturnsMatching() { var (registration, provider) = CreateCatalog(); registration.RegisterEvent("EventX", null, new[] { "admin" }, new Lazy(() => "{}")); registration.RegisterEvent("EventY", null, new[] { "user" }, new Lazy(() => "{}")); - var entries = provider.ListEvents(constraints: new[] { "admin" }); + var entries = await provider.ListEvents(constraints: new[] { "admin" }); entries.Should().ContainSingle() .Which.EventName.Should().Be("EventX"); } [Fact] - public void GetEntries_FilterByNameAndConstraints_ReturnsMatching() + public async Task GetEntries_FilterByNameAndConstraints_ReturnsMatching() { var (registration, provider) = CreateCatalog(); @@ -71,33 +72,33 @@ public void GetEntries_FilterByNameAndConstraints_ReturnsMatching() registration.RegisterEvent("EventA", null, new[] { "user" }, new Lazy(() => "{}")); registration.RegisterEvent("EventB", null, new[] { "admin" }, new Lazy(() => "{}")); - var entries = provider.ListEvents(name: "EventA", constraints: new[] { "admin" }); + var entries = await provider.ListEvents(name: "EventA", constraints: new[] { "admin" }); entries.Should().ContainSingle() .Which.EventName.Should().Be("EventA"); } [Fact] - public void GetEntries_ForUnknownEntity_ReturnsEmpty() + public async Task GetEntries_ForUnknownEntity_ReturnsEmpty() { var (registration, provider) = CreateCatalog(); registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}")); - var entries = provider.ListEvents(); + var entries = await provider.ListEvents(); entries.Should().BeEmpty(); } [Fact] - public void GetDetails_ReturnsSchemaAndDescription() + public async Task GetDetails_ReturnsSchemaAndDescription() { var (registration, provider) = CreateCatalog(); const string schema = """{"type":"object","properties":{"id":{"type":"string"}}}"""; registration.RegisterEvent("DetailedEvent", "Has a schema", new[] { "v1" }, new Lazy(() => schema)); - var details = provider.GetEventDetails("DetailedEvent"); + var details = await provider.GetEventDetails("DetailedEvent"); details.Should().NotBeNull(); details!.EventName.Should().Be("DetailedEvent"); @@ -107,17 +108,17 @@ public void GetDetails_ReturnsSchemaAndDescription() } [Fact] - public void GetDetails_UnknownEvent_ReturnsNull() + public async Task GetDetails_UnknownEvent_ReturnsNull() { var (_, provider) = CreateCatalog(); - var details = provider.GetEventDetails("NonExistent"); + var details = await provider.GetEventDetails("NonExistent"); details.Should().BeNull(); } [Fact] - public void GetDetails_LazySchemaEvaluatedOnAccess() + public async Task GetDetails_LazySchemaEvaluatedOnAccess() { var (registration, provider) = CreateCatalog(); bool evaluated = false; @@ -131,7 +132,7 @@ public void GetDetails_LazySchemaEvaluatedOnAccess() evaluated.Should().BeFalse("schema should not be evaluated at registration time"); - var details = provider.GetEventDetails("LazyEvent"); + var details = await provider.GetEventDetails("LazyEvent"); evaluated.Should().BeTrue("schema should be evaluated when details are requested"); details!.JsonSchema.Should().Be("""{"type":"object"}"""); @@ -140,33 +141,33 @@ public void GetDetails_LazySchemaEvaluatedOnAccess() // --- EventCatalogProvider delegation tests --- [Fact] - public void ListEvents_DelegatesToRegistrations() + public async Task ListEvents_DelegatesToRegistrations() { var registration = new EventCatalogRegistration(); registration.RegisterEvent("Evt1", "desc", null, new Lazy(() => "{}")); var provider = new EventCatalogProvider(new[] { registration }); - var entries = provider.ListEvents(); + var entries = await provider.ListEvents(); entries.Should().ContainSingle() .Which.Should().BeEquivalentTo(new EventCatalogEntry("Evt1", "desc", null)); } [Fact] - public void GetEventDetails_DelegatesToRegistrations() + public async Task GetEventDetails_DelegatesToRegistrations() { var registration = new EventCatalogRegistration(); registration.RegisterEvent("Evt2", "desc2", new[] { "c1" }, new Lazy(() => """{"schema":true}""")); var provider = new EventCatalogProvider(new[] { registration }); - var details = provider.GetEventDetails("Evt2"); + var details = await provider.GetEventDetails("Evt2"); details.Should().NotBeNull(); details.Should().BeEquivalentTo(new EventCatalogEventDetails("Evt2", "desc2", new[] { "c1" }, """{"schema":true}""")); } [Fact] - public void ListEvents_CombinesMultipleRegistrations() + public async Task ListEvents_CombinesMultipleRegistrations() { var reg1 = new EventCatalogRegistration(); reg1.RegisterEvent("FromReg1", null, null, new Lazy(() => "{}")); @@ -176,9 +177,57 @@ public void ListEvents_CombinesMultipleRegistrations() var provider = new EventCatalogProvider(new IEventCatalogRegistration[] { reg1, reg2 }); - var entries = provider.ListEvents(); + var entries = await provider.ListEvents(); entries.Should().HaveCount(2); entries.Select(e => e.EventName).Should().BeEquivalentTo("FromReg1", "FromReg2"); } + + // --- Entity-scoped GetEventDetails tests --- + + [Fact] + public async Task GetEventDetails_EntityScoped_ReturnsDetailsForEntity() + { + var (registration, provider) = CreateCatalog(); + + registration.RegisterEvent("SharedEvent", "Test version", new[] { "test" }, new Lazy(() => """{"entity":"test"}""")); + registration.RegisterEvent("SharedEvent", "Other version", new[] { "other" }, new Lazy(() => """{"entity":"other"}""")); + + var testDetails = await provider.GetEventDetails("SharedEvent"); + var otherDetails = await provider.GetEventDetails("SharedEvent"); + + testDetails.Should().NotBeNull(); + testDetails!.Description.Should().Be("Test version"); + testDetails.JsonSchema.Should().Be("""{"entity":"test"}"""); + + otherDetails.Should().NotBeNull(); + otherDetails!.Description.Should().Be("Other version"); + otherDetails.JsonSchema.Should().Be("""{"entity":"other"}"""); + } + + [Fact] + public async Task GetEventDetails_EntityScoped_UnknownEntity_ReturnsNull() + { + var (registration, provider) = CreateCatalog(); + + registration.RegisterEvent("SomeEvent", null, null, new Lazy(() => "{}")); + + var details = await provider.GetEventDetails("SomeEvent"); + + details.Should().BeNull(); + } + + [Fact] + public async Task GetEventDetails_DuplicateEventNamesAcrossEntities_GlobalReturnsFirst() + { + var (registration, provider) = CreateCatalog(); + + registration.RegisterEvent("DuplicateName", "First", null, new Lazy(() => """{"first":true}""")); + registration.RegisterEvent("DuplicateName", "Second", null, new Lazy(() => """{"second":true}""")); + + var details = await provider.GetEventDetails("DuplicateName"); + + details.Should().NotBeNull(); + details!.Description.Should().Be("First"); + } } From 9f75fc540be2578a078eecacd2dc171edcf851b7 Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Sat, 11 Apr 2026 17:15:46 +0200 Subject: [PATCH 3/5] Implement EventCatalogAmbiguousEventException and update event catalog methods to handle ambiguous event names --- ...entRegistrationIncrementalCodeGenerator.cs | 16 +++++- .../EventCatalog/EventCatalogProvider.cs | 16 ++++-- .../EventCatalog/EventCatalogRegistration.cs | 37 ++++++++----- .../EventCatalog/IEventCatalog.cs | 3 +- .../EventCatalog/IEventCatalogRegistration.cs | 9 ++-- .../EventCatalogAmbiguousEventException.cs | 54 +++++++++++++++++++ .../CodeGeneratorCatalogTests.cs | 44 +++++++++++++++ .../EventCatalog/EventCatalogProviderTests.cs | 26 +++++++-- 8 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 src/Papst.EventStore/Exceptions/EventCatalogAmbiguousEventException.cs diff --git a/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs b/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs index c38db4d..154cf0d 100644 --- a/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs +++ b/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs @@ -575,11 +575,24 @@ private static string GetSimpleName(NameSyntax name) return ins.Identifier.ValueText; if (name is GenericNameSyntax gns) return gns.Identifier.ValueText; + if (name is AliasQualifiedNameSyntax aqns) + return GetSimpleName(aqns.Name); if (name is QualifiedNameSyntax qns) return GetSimpleName(qns.Right); return ""; } + private static GenericNameSyntax GetGenericName(NameSyntax name) + { + if (name is GenericNameSyntax gns) + return gns; + if (name is AliasQualifiedNameSyntax aqns) + return GetGenericName(aqns.Name); + if (name is QualifiedNameSyntax qns) + return GetGenericName(qns.Right); + return null; + } + private static EventAttributeInfo ParseEventNameAttribute( AttributeSyntax attr, SemanticModel semanticModel, @@ -594,7 +607,8 @@ private static EventAttributeInfo ParseEventNameAttribute( string entityTypeNamespace = null; // Check if this is a generic attribute EventName - if (attr.Name is GenericNameSyntax gns && gns.TypeArgumentList.Arguments.Count == 1) + GenericNameSyntax gns = GetGenericName(attr.Name); + if (gns != null && gns.TypeArgumentList.Arguments.Count == 1) { isGeneric = true; var entityTypeSyntax = gns.TypeArgumentList.Arguments[0]; diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs index 3c3bf2e..77ffff6 100644 --- a/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs +++ b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Linq; +using Papst.EventStore.Exceptions; namespace Papst.EventStore.EventCatalog; @@ -30,11 +31,18 @@ public ValueTask> ListEvents(string? n /// public ValueTask GetEventDetails(string eventName) { - EventCatalogEventDetails? result = _registrations + List matches = _registrations .Select(r => r.GetDetails(eventName)) - .FirstOrDefault(d => d is not null); + .Where(d => d is not null) + .Cast() + .ToList(); - return new ValueTask(result); + if (matches.Count > 1) + { + throw new EventCatalogAmbiguousEventException(eventName, matches.Count); + } + + return new ValueTask(matches.FirstOrDefault()); } /// diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs index 245a671..2c5d77f 100644 --- a/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs +++ b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Linq; +using Papst.EventStore.Exceptions; namespace Papst.EventStore.EventCatalog; @@ -26,7 +27,7 @@ public void RegisterEvent(string eventName, string? description, string } /// - IReadOnlyList IEventCatalogRegistration.GetEntries(Type entityType, string? name, string[]? constraints) + public IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints) { if (!_entityEvents.TryGetValue(entityType, out List? items)) { @@ -54,23 +55,29 @@ i.Constraints is not null && } /// - EventCatalogEventDetails? IEventCatalogRegistration.GetDetails(string eventName) + public EventCatalogEventDetails? GetDetails(string eventName) { - foreach (List items in _entityEvents.Values) + List<(Type EntityType, CatalogItem Item)> matches = _entityEvents + .SelectMany(pair => pair.Value + .Where(item => item.EventName == eventName) + .Select(item => (pair.Key, item))) + .ToList(); + + if (matches.Count == 0) + { + return null; + } + + if (matches.Count > 1) { - CatalogItem? found = items.Cast().FirstOrDefault(i => i!.Value.EventName == eventName); - if (found.HasValue) - { - CatalogItem item = found.Value; - return new EventCatalogEventDetails(item.EventName, item.Description, item.Constraints, item.SchemaJson.Value); - } + throw new EventCatalogAmbiguousEventException(eventName, matches.Select(match => match.EntityType), matches.Count); } - return null; + return CreateDetails(matches[0].Item); } /// - EventCatalogEventDetails? IEventCatalogRegistration.GetDetails(Type entityType, string eventName) + public EventCatalogEventDetails? GetDetails(Type entityType, string eventName) { if (!_entityEvents.TryGetValue(entityType, out List? items)) { @@ -83,7 +90,11 @@ i.Constraints is not null && return null; } - CatalogItem item = found.Value; + return CreateDetails(found.Value); + } + + private static EventCatalogEventDetails CreateDetails(CatalogItem item) + { return new EventCatalogEventDetails(item.EventName, item.Description, item.Constraints, item.SchemaJson.Value); } } diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalog.cs b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs index 7354830..ac78a57 100644 --- a/src/Papst.EventStore/EventCatalog/IEventCatalog.cs +++ b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs @@ -16,10 +16,11 @@ public interface IEventCatalog /// /// Get detailed information (including JSON Schema) for a specific event by name. - /// If the same event name exists for multiple entities, the first match is returned. + /// Throws when the same event name exists for multiple registrations. /// /// The event name to look up /// The or null if the event is not registered + /// Thrown when the event name matches more than one registration. ValueTask GetEventDetails(string eventName); /// diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs index 668eb12..f107fe4 100644 --- a/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs +++ b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs @@ -18,15 +18,16 @@ public interface IEventCatalogRegistration /// /// Get catalog entries for a given entity type, optionally filtered /// - internal IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints); + IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints); /// - /// Get detailed event information by name (first match across all entities) + /// Get detailed event information by name. + /// Throws when more than one event with the same name is registered. /// - internal EventCatalogEventDetails? GetDetails(string eventName); + EventCatalogEventDetails? GetDetails(string eventName); /// /// Get detailed event information by name, scoped to a specific entity type /// - internal EventCatalogEventDetails? GetDetails(Type entityType, string eventName); + EventCatalogEventDetails? GetDetails(Type entityType, string eventName); } diff --git a/src/Papst.EventStore/Exceptions/EventCatalogAmbiguousEventException.cs b/src/Papst.EventStore/Exceptions/EventCatalogAmbiguousEventException.cs new file mode 100644 index 0000000..903a63b --- /dev/null +++ b/src/Papst.EventStore/Exceptions/EventCatalogAmbiguousEventException.cs @@ -0,0 +1,54 @@ +using System.Linq; + +namespace Papst.EventStore.Exceptions; + +/// +/// Exception that is thrown when an event catalog lookup by event name matches more than one registration. +/// +public class EventCatalogAmbiguousEventException : Exception +{ + public EventCatalogAmbiguousEventException(string eventName, IEnumerable matchingEntityTypes) + : this(eventName, matchingEntityTypes, null) + { } + + public EventCatalogAmbiguousEventException(string eventName, int matchCount) + : this(eventName, null, matchCount) + { } + + public EventCatalogAmbiguousEventException(string eventName, IEnumerable? matchingEntityTypes, int? matchCount = null) + : base(CreateMessage(eventName, matchingEntityTypes, matchCount)) + { + EventName = eventName; + MatchingEntityTypes = matchingEntityTypes?.Distinct().ToArray() ?? Array.Empty(); + MatchCount = matchCount ?? MatchingEntityTypes.Count; + } + + /// + /// The ambiguous event name. + /// + public string EventName { get; } + + /// + /// The entity types that matched the event name, when known. + /// + public IReadOnlyList MatchingEntityTypes { get; } + + /// + /// The number of matches that made the lookup ambiguous. + /// + public int MatchCount { get; } + + private static string CreateMessage(string eventName, IEnumerable? matchingEntityTypes, int? matchCount) + { + Type[] entityTypes = matchingEntityTypes?.Distinct().ToArray() ?? Array.Empty(); + int resolvedMatchCount = matchCount ?? entityTypes.Length; + + if (entityTypes.Length == 0) + { + return $"Event catalog lookup for '{eventName}' is ambiguous because {resolvedMatchCount} matching registrations were found."; + } + + string typeNames = string.Join(", ", entityTypes.Select(t => t.FullName ?? t.Name)); + return $"Event catalog lookup for '{eventName}' is ambiguous because it matches {resolvedMatchCount} registrations for entity types: {typeNames}."; + } +} diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs index 79435fa..3ece9ad 100644 --- a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs +++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs @@ -163,4 +163,48 @@ public record SimpleEvent(string Value); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); source.Should().Contain("catalog.RegisterEvent(\"SimpleEvent\", null, null,"); } + + [Fact] + public void TestQualifiedGenericEventNameAttributeGeneratesCatalog() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + public class OrderEntity {} + [Papst.EventStore.Aggregation.EventRegistration.EventName(""OrderCreated"")] + public record OrderCreatedEvent(string Number); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("catalog.RegisterEvent(\"OrderCreated\", null, null,"); + } + + [Fact] + public void TestGlobalQualifiedGenericEventNameAttributeGeneratesCatalog() + { + var generator = new EventRegistrationIncrementalCodeGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + Compilation inputCompilation = CreateCompilation(@" +namespace MyCode +{ + public class Program { public static void Main(string[] args) {} } + public class InvoiceEntity {} + [global::Papst.EventStore.Aggregation.EventRegistration.EventName(""InvoiceIssued"")] + public record InvoiceIssuedEvent(string Number); +} +"); + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); + diagnostics.Should().BeEmpty(); + var runResult = driver.GetRunResult(); + runResult.Diagnostics.Should().BeEmpty(); + var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); + source.Should().Contain("catalog.RegisterEvent(\"InvoiceIssued\", null, null,"); + } } diff --git a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs index b276d3a..cf3f8f0 100644 --- a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs +++ b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using Papst.EventStore.EventCatalog; +using Papst.EventStore.Exceptions; using Xunit; namespace Papst.EventStore.Tests.EventCatalog; @@ -218,16 +219,33 @@ public async Task GetEventDetails_EntityScoped_UnknownEntity_ReturnsNull() } [Fact] - public async Task GetEventDetails_DuplicateEventNamesAcrossEntities_GlobalReturnsFirst() + public async Task GetEventDetails_DuplicateEventNamesAcrossEntities_GlobalThrowsAmbiguousException() { var (registration, provider) = CreateCatalog(); registration.RegisterEvent("DuplicateName", "First", null, new Lazy(() => """{"first":true}""")); registration.RegisterEvent("DuplicateName", "Second", null, new Lazy(() => """{"second":true}""")); - var details = await provider.GetEventDetails("DuplicateName"); + Func act = async () => _ = await provider.GetEventDetails("DuplicateName"); - details.Should().NotBeNull(); - details!.Description.Should().Be("First"); + await act.Should().ThrowAsync() + .WithMessage("*DuplicateName*"); + } + + [Fact] + public async Task GetEventDetails_DuplicateEventNamesAcrossRegistrations_GlobalThrowsAmbiguousException() + { + var reg1 = new EventCatalogRegistration(); + reg1.RegisterEvent("DuplicateName", "First", null, new Lazy(() => """{"first":true}""")); + + var reg2 = new EventCatalogRegistration(); + reg2.RegisterEvent("DuplicateName", "Second", null, new Lazy(() => """{"second":true}""")); + + var provider = new EventCatalogProvider(new IEventCatalogRegistration[] { reg1, reg2 }); + + Func act = async () => _ = await provider.GetEventDetails("DuplicateName"); + + await act.Should().ThrowAsync() + .WithMessage("*DuplicateName*"); } } From 10a2e3cd2738a1a8948cdf4d6703b42f19276113 Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Sat, 11 Apr 2026 17:42:11 +0200 Subject: [PATCH 4/5] Update Packages and Migrate from FluentAssertions to Shouldly --- .github/copilot-instructions.md | 2 +- Directory.Packages.props | 13 +- .../IntegrationTests/CosmosEventStoreTests.cs | 12 +- .../CosmosEventStreamTests.cs | 43 ++++-- .../Papst.EventStore.AzureCosmos.Tests.csproj | 2 +- .../CodeGeneratorCatalogTests.cs | 78 +++++------ .../CodeGeneratorTests.cs | 122 +++++++++--------- ...pst.EventStore.CodeGeneration.Tests.csproj | 4 +- .../MongoDBEventStoreTests.cs | 18 +-- .../MongoDBEventStreamTests.cs | 27 ++-- .../Papst.EventStore.MongoDB.Tests.csproj | 2 +- .../AggregationContextDataTests.cs | 36 +++--- .../EventAggregatorBaseTests.cs | 26 ++-- .../EventCatalog/EventCatalogProviderTests.cs | 79 ++++++------ .../EventRegistrationTests.cs | 10 +- .../EventStreamMetadataTests.cs | 38 ++++-- .../Papst.EventStore.Tests.csproj | 4 +- .../InMemoryEventStoreTests.cs | 6 +- .../InMemoryEventStreamTests.cs | 12 +- .../Papst.EventsStore.InMemory.Tests.csproj | 2 +- 20 files changed, 293 insertions(+), 243 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7e64f9d..4c5021c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,7 +28,7 @@ samples/ # Sample applications ## Technology Stack - **Language**: C# on .NET 10 -- **Test framework**: xUnit with AutoFixture, FluentAssertions, and Moq +- **Test framework**: xUnit with AutoFixture, Shouldly, and Moq - **Serialization**: Newtonsoft.Json - **Versioning**: Nerdbank.GitVersioning (`version.json`) - **Package management**: Central package management via `Directory.Packages.props` diff --git a/Directory.Packages.props b/Directory.Packages.props index 318f773..9e04c49 100755 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,14 +5,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -37,11 +37,12 @@ - - + + + @@ -53,4 +54,4 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - \ No newline at end of file + diff --git a/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStoreTests.cs b/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStoreTests.cs index ce3fa2f..603bb33 100755 --- a/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStoreTests.cs +++ b/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStoreTests.cs @@ -1,10 +1,10 @@ using AutoFixture.Xunit2; -using FluentAssertions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; using Microsoft.Extensions.DependencyInjection; using Papst.EventStore.AzureCosmos.Database; using Papst.EventStore.Exceptions; +using Shouldly; using Xunit; namespace Papst.EventStore.AzureCosmos.Tests.IntegrationTests; @@ -31,8 +31,8 @@ public async Task CreateAsync_ShouldCreateIndexDocument(Guid streamId) CosmosDbIntegrationTestFixture.CosmosContainerId); var iterator = container.GetItemLinqQueryable().ToFeedIterator(); var batch = await iterator.ReadNextAsync(); - batch.Count.Should().Be(1); - batch.Resource.First().StreamId.Should().Be(streamId); + batch.Count.ShouldBe(1); + batch.Resource.First().StreamId.ShouldBe(streamId); } [Theory, AutoData] @@ -46,7 +46,7 @@ public async Task GetAsync_ShouldThrow_WhenNotFound(Guid streamId) Func act = () => eventStore.GetAsync(streamId, CancellationToken.None); // assert - await act.Should().ThrowAsync(); + await Should.ThrowAsync(act); } [Theory, AutoData] @@ -63,7 +63,7 @@ public async Task GetAsync_ShouldReturnStream(EventStreamIndexEntity index) var stream = await eventStore.GetAsync(index.StreamId, CancellationToken.None); // assert - stream.Should().NotBeNull(); - stream.StreamId.Should().Be(index.StreamId); + stream.ShouldNotBeNull(); + stream.StreamId.ShouldBe(index.StreamId); } } diff --git a/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStreamTests.cs b/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStreamTests.cs index 6f58973..cf2a69d 100755 --- a/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStreamTests.cs +++ b/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStreamTests.cs @@ -1,9 +1,9 @@ using AutoFixture.Xunit2; -using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Papst.EventStore.Aggregation; using Papst.EventStore.AzureCosmos.Tests.IntegrationTests.Models; using Papst.EventStore.Documents; +using Shouldly; using Xunit; namespace Papst.EventStore.AzureCosmos.Tests.IntegrationTests; @@ -28,6 +28,26 @@ private async Task CreateStreamAsync(IEventStore store, Guid strea return stream; } + private static void ShouldMatch(EventStreamMetaData actual, EventStreamMetaData expected) + { + actual.UserId.ShouldBe(expected.UserId); + actual.UserName.ShouldBe(expected.UserName); + actual.TenantId.ShouldBe(expected.TenantId); + actual.Comment.ShouldBe(expected.Comment); + if (expected.Additional is null) + { + actual.Additional.ShouldBeNull(); + return; + } + + actual.Additional.ShouldNotBeNull(); + actual.Additional.Count.ShouldBe(expected.Additional.Count); + foreach (var entry in expected.Additional) + { + actual.Additional[entry.Key].ShouldBe(entry.Value); + } + } + [Theory, AutoData] public async Task AppendAsync_ShouldAppendEvent(Guid streamId, Guid documentId) { @@ -40,9 +60,9 @@ public async Task AppendAsync_ShouldAppendEvent(Guid streamId, Guid documentId) await stream.AppendAsync(documentId, new TestAppendedEvent()); // assert - stream.Version.Should().Be(1); + stream.Version.ShouldBe(1UL); var events = await stream.ListAsync().ToListAsync(); - events.Should().Contain(d => d.Id == documentId); + events.ShouldContain(d => d.Id == documentId); } [Theory, AutoData] @@ -63,7 +83,7 @@ public async Task AppendTransactionAsync_ShouldAppendEvents(Guid streamId) // assert var events = await stream.ListAsync(0).ToListAsync(); - events.Should().HaveCount(5); + events.Count.ShouldBe(5); } @@ -85,9 +105,10 @@ public async Task AppenSnapshotAsync_ShouldAppendSnapshot(Guid streamId, Guid do }; // assert - await act.Should().NotThrowAsync(); - entity.Should().NotBeNull(); - stream.LatestSnapshotVersion.Should().HaveValue().And.Be(entity!.Version); + await Should.NotThrowAsync(act); + entity.ShouldNotBeNull(); + stream.LatestSnapshotVersion.HasValue.ShouldBeTrue(); + stream.LatestSnapshotVersion.ShouldBe(entity.Version); } [Theory, AutoData] @@ -103,7 +124,7 @@ public async Task ListAsync_ShouldListEvents(Guid streamId) var events = await list.ToListAsync(); // assert - events.Count.Should().Be(4, "3 TestAppendedEvent + 1 TestCreatedEvent"); + events.Count.ShouldBe(4); } [Theory, AutoData] @@ -118,7 +139,8 @@ public async Task UpdateMetadata_ShouldUpdate(Guid streamId, EventStreamMetaData await stream.UpdateStreamMetaData(metaData, default); // assert - stream.MetaData.Should().NotBeNull().And.BeEquivalentTo(metaData); + stream.MetaData.ShouldNotBeNull(); + ShouldMatch(stream.MetaData, metaData); } [Theory, AutoData] @@ -135,6 +157,7 @@ public async Task UpdateMetadata_ShouldPersist(Guid streamId, EventStreamMetaDat stream = await store.GetAsync(streamId, default); // assert - stream.MetaData.Should().NotBeNull().And.BeEquivalentTo(metaData); + stream.MetaData.ShouldNotBeNull(); + ShouldMatch(stream.MetaData, metaData); } } diff --git a/tests/Papst.EventStore.AzureCosmos.Tests/Papst.EventStore.AzureCosmos.Tests.csproj b/tests/Papst.EventStore.AzureCosmos.Tests/Papst.EventStore.AzureCosmos.Tests.csproj index 136b1da..3a1d6e7 100755 --- a/tests/Papst.EventStore.AzureCosmos.Tests/Papst.EventStore.AzureCosmos.Tests.csproj +++ b/tests/Papst.EventStore.AzureCosmos.Tests/Papst.EventStore.AzureCosmos.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs index 3ece9ad..9912019 100644 --- a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs +++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs @@ -1,7 +1,7 @@ -using FluentAssertions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using System.Linq; +using Shouldly; using Xunit; namespace Papst.EventStore.CodeGeneration.Tests; @@ -23,16 +23,16 @@ public record UserCreatedEvent(string Name); } "); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); + diagnostics.ShouldBeEmpty(); var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("AddCodeGeneratedEventCatalog"); - source.Should().Contain("catalog.RegisterEvent("); - source.Should().Contain("\"UserCreated\""); - source.Should().Contain("\"A user was created\""); - source.Should().Contain("new string[] { \"Create\" }"); - source.Should().Contain("\"\"name\"\""); + source.ShouldContain("AddCodeGeneratedEventCatalog"); + source.ShouldContain("catalog.RegisterEvent("); + source.ShouldContain("\"UserCreated\""); + source.ShouldContain("\"A user was created\""); + source.ShouldContain("new string[] { \"Create\" }"); + source.ShouldContain("\"\"name\"\""); } [Fact] @@ -55,15 +55,15 @@ public class FooAggregator : EventAggregatorBase } "); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); + diagnostics.ShouldBeEmpty(); var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("AddCodeGeneratedEventCatalog"); - source.Should().Contain("catalog.RegisterEvent("); - source.Should().Contain("\"FooEvent\""); - source.Should().Contain("\"\"value\"\""); - source.Should().Contain("\"\"integer\"\""); + source.ShouldContain("AddCodeGeneratedEventCatalog"); + source.ShouldContain("catalog.RegisterEvent("); + source.ShouldContain("\"FooEvent\""); + source.ShouldContain("\"\"value\"\""); + source.ShouldContain("\"\"integer\"\""); } [Fact] @@ -82,15 +82,15 @@ public record ComplexEvent(string Name, int Count, bool IsActive, Status Status, } "); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); + diagnostics.ShouldBeEmpty(); var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("\"\"name\"\":{\"\"type\"\":\"\"string\"\"}"); - source.Should().Contain("\"\"count\"\":{\"\"type\"\":\"\"integer\"\"}"); - source.Should().Contain("\"\"isActive\"\":{\"\"type\"\":\"\"boolean\"\"}"); - source.Should().Contain("\"\"status\"\":{\"\"type\"\":\"\"string\"\",\"\"enum\"\":[\"\"Active\"\",\"\"Inactive\"\"]}"); - source.Should().Contain("\"\"tags\"\":{\"\"type\"\":\"\"array\"\",\"\"items\"\":{\"\"type\"\":\"\"string\"\"}}"); + source.ShouldContain("\"\"name\"\":{\"\"type\"\":\"\"string\"\"}"); + source.ShouldContain("\"\"count\"\":{\"\"type\"\":\"\"integer\"\"}"); + source.ShouldContain("\"\"isActive\"\":{\"\"type\"\":\"\"boolean\"\"}"); + source.ShouldContain("\"\"status\"\":{\"\"type\"\":\"\"string\"\",\"\"enum\"\":[\"\"Active\"\",\"\"Inactive\"\"]}"); + source.ShouldContain("\"\"tags\"\":{\"\"type\"\":\"\"array\"\",\"\"items\"\":{\"\"type\"\":\"\"string\"\"}}"); } [Fact] @@ -113,11 +113,11 @@ public class BarAggregator : EventAggregatorBase } "); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); + diagnostics.ShouldBeEmpty(); var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("catalog.RegisterEvent(\"BarEvent\", \"Bar happened\", new string[] { \"Read\", \"Write\" },"); + source.ShouldContain("catalog.RegisterEvent(\"BarEvent\", \"Bar happened\", new string[] { \"Read\", \"Write\" },"); } [Fact] @@ -134,12 +134,12 @@ public record OrphanEvent(string Value); } "); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); + diagnostics.ShouldBeEmpty(); var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().NotContain("AddCodeGeneratedEventCatalog"); - source.Should().Contain("AddCodeGeneratedEvents"); + source.ShouldNotContain("AddCodeGeneratedEventCatalog"); + source.ShouldContain("AddCodeGeneratedEvents"); } [Fact] @@ -157,11 +157,11 @@ public record SimpleEvent(string Value); } "); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); + diagnostics.ShouldBeEmpty(); var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("catalog.RegisterEvent(\"SimpleEvent\", null, null,"); + source.ShouldContain("catalog.RegisterEvent(\"SimpleEvent\", null, null,"); } [Fact] @@ -179,11 +179,11 @@ public record OrderCreatedEvent(string Number); } "); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); + diagnostics.ShouldBeEmpty(); var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("catalog.RegisterEvent(\"OrderCreated\", null, null,"); + source.ShouldContain("catalog.RegisterEvent(\"OrderCreated\", null, null,"); } [Fact] @@ -201,10 +201,10 @@ public record InvoiceIssuedEvent(string Number); } "); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); + diagnostics.ShouldBeEmpty(); var runResult = driver.GetRunResult(); - runResult.Diagnostics.Should().BeEmpty(); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("catalog.RegisterEvent(\"InvoiceIssued\", null, null,"); + source.ShouldContain("catalog.RegisterEvent(\"InvoiceIssued\", null, null,"); } } diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs index 465cb17..f1b0895 100644 --- a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs +++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs @@ -1,9 +1,9 @@ -using FluentAssertions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using System.Linq; using System.Reflection; +using Shouldly; using Xunit; namespace Papst.EventStore.CodeGeneration.Tests; @@ -32,16 +32,16 @@ public record Foo {} driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("namespace MyCode"); + source.ShouldContain("namespace MyCode"); } [Fact] @@ -60,17 +60,17 @@ public record Foo {} driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); //source.Should().Contain("namespace MyCode"); - source.Should().Contain("namespace compilation", "This does not return MyCode, because the compiling process is called compilation!"); + source.ShouldContain("namespace compilation"); } [Fact] @@ -98,16 +98,16 @@ public class TestEventFoo driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); + source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); } [Fact] @@ -135,16 +135,16 @@ public class TestEventFoo driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); + source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); } @@ -171,16 +171,16 @@ public class TestEventFoo driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); + source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); } @@ -211,16 +211,16 @@ public class TestEventFoo driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"FooOld\", false), new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); + source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"FooOld\", false), new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); } @@ -251,16 +251,16 @@ public class TestEventFoo driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"FooOld\", false), new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); + source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"FooOld\", false), new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); } @@ -289,16 +289,16 @@ public record TestEventFoo driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); + source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); } @@ -336,17 +336,17 @@ public class TestEventFooAgg : EventAggregatorBase driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); - source.Should().Contain("services.AddTransient, MyCode.TestEventFooAgg"); + source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); + source.ShouldContain("services.AddTransient, MyCode.TestEventFooAgg"); } [Fact] @@ -389,18 +389,18 @@ public class TestEventFooAgg : EventAggregatorBase, Eve driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); - source.Should().Contain("services.AddTransient, MyCode.TestEventFooAgg"); - source.Should().Contain("services.AddTransient, MyCode.TestEventFooAgg"); + source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));"); + source.ShouldContain("services.AddTransient, MyCode.TestEventFooAgg"); + source.ShouldContain("services.AddTransient, MyCode.TestEventFooAgg"); } [Fact] @@ -437,16 +437,16 @@ internal class TestEventFooAgg : EventAggregatorBase driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().BeEmpty(); - outputCompilation.SyntaxTrees.Should().HaveCount(2); + diagnostics.ShouldBeEmpty(); + outputCompilation.SyntaxTrees.Count().ShouldBe(2); var runResult = driver.GetRunResult(); - runResult.GeneratedTrees.Should().HaveCount(1); - runResult.Diagnostics.Should().BeEmpty(); + runResult.GeneratedTrees.Length.ShouldBe(1); + runResult.Diagnostics.ShouldBeEmpty(); var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString(); - source.Should().Contain("services.AddTransient, MyCode.TestEventFooAgg"); + source.ShouldContain("services.AddTransient, MyCode.TestEventFooAgg"); } [Fact] @@ -467,7 +467,7 @@ public static void Main(string[] args) }"); driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics); - diagnostics.Should().HaveCount(1); - diagnostics.First().Id.Should().Be("EVTSRC0002"); + diagnostics.Length.ShouldBe(1); + diagnostics.First().Id.ShouldBe("EVTSRC0002"); } } diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/Papst.EventStore.CodeGeneration.Tests.csproj b/tests/Papst.EventStore.CodeGeneration.Tests/Papst.EventStore.CodeGeneration.Tests.csproj index 5749584..2973571 100644 --- a/tests/Papst.EventStore.CodeGeneration.Tests/Papst.EventStore.CodeGeneration.Tests.csproj +++ b/tests/Papst.EventStore.CodeGeneration.Tests/Papst.EventStore.CodeGeneration.Tests.csproj @@ -13,7 +13,7 @@ - + @@ -38,4 +38,4 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - \ No newline at end of file + diff --git a/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStoreTests.cs b/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStoreTests.cs index 5b24def..83a9043 100644 --- a/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStoreTests.cs +++ b/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStoreTests.cs @@ -2,9 +2,9 @@ using System.Threading; using System.Threading.Tasks; using AutoFixture.Xunit2; -using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Papst.EventStore.Exceptions; +using Shouldly; using Xunit; namespace Papst.EventStore.MongoDB.Tests.IntegrationTests; @@ -27,8 +27,8 @@ public async Task CreateAsync_ShouldCreateStream(Guid streamId) // assert var stream = await eventStore.GetAsync(streamId, CancellationToken.None); - stream.StreamId.Should().Be(streamId); - stream.Version.Should().Be(0); + stream.StreamId.ShouldBe(streamId); + stream.Version.ShouldBe(0UL); } [Theory, AutoData] @@ -43,12 +43,12 @@ public async Task CreateAsync_WithMetadata_ShouldCreateStreamWithMetadata(Guid s // assert var stream = await eventStore.GetAsync(streamId, CancellationToken.None); - stream.StreamId.Should().Be(streamId); - stream.Version.Should().Be(0); - stream.MetaData.TenantId.Should().Be(tenantId); - stream.MetaData.UserId.Should().Be(userId); - stream.MetaData.UserName.Should().Be(username); - stream.MetaData.Comment.Should().Be(comment); + stream.StreamId.ShouldBe(streamId); + stream.Version.ShouldBe(0UL); + stream.MetaData.TenantId.ShouldBe(tenantId); + stream.MetaData.UserId.ShouldBe(userId); + stream.MetaData.UserName.ShouldBe(username); + stream.MetaData.Comment.ShouldBe(comment); } [Theory, AutoData] diff --git a/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStreamTests.cs b/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStreamTests.cs index 89dcab6..ce15776 100644 --- a/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStreamTests.cs +++ b/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStreamTests.cs @@ -4,9 +4,9 @@ using System.Threading; using System.Threading.Tasks; using AutoFixture.Xunit2; -using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Papst.EventStore.MongoDB.Tests.IntegrationTests.Events; +using Shouldly; using Xunit; namespace Papst.EventStore.MongoDB.Tests.IntegrationTests; @@ -29,7 +29,7 @@ public async Task ListAsync_ShouldReturnEmpty(Guid streamId) var events = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None); // assert - events.Should().BeEmpty(); + events.ShouldBeEmpty(); } [Theory, AutoData] @@ -45,8 +45,8 @@ public async Task AppendAsync_ShouldAppendEvent(Guid streamId, TestEvent testEve // assert var events = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None); - events.Should().HaveCount(1); - events[0].Data.ToObject().Should().BeEquivalentTo(testEvent); + events.Count.ShouldBe(1); + events[0].Data.ToObject().ShouldBe(testEvent); } [Theory, AutoData] @@ -66,8 +66,12 @@ public async Task ListAsync_ShouldReturnEntries(List testEvents, Guid var result = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None); // assert - result.Should().HaveCount(testEvents.Count); - result.Select(e => e.Data.ToObject()).Should().BeEquivalentTo(testEvents); + result.Count.ShouldBe(testEvents.Count); + result.Select(e => e.Data.ToObject()) + .Where(e => e is not null) + .Select(e => e!) + .ToList() + .ShouldBe(testEvents); } [Theory, AutoData] @@ -87,8 +91,9 @@ public async Task ListDescendingAsync_ShouldReturnEntriesInDescendingOrder(List< var result = await stream.ListDescendingAsync((ulong)testEvents.Count, CancellationToken.None).ToListAsync(CancellationToken.None); // assert - result.Should().HaveCount(testEvents.Count); - result.Select(e => e.Version).Should().BeInDescendingOrder(); + result.Count.ShouldBe(testEvents.Count); + var versions = result.Select(e => e.Version).ToList(); + versions.ShouldBe(versions.OrderByDescending(version => version).ToList()); } [Theory, AutoData] @@ -104,8 +109,8 @@ public async Task AppendSnapshotAsync_ShouldAppendSnapshot(Guid streamId, TestEv // assert var latestSnapshot = await stream.GetLatestSnapshot(CancellationToken.None); - latestSnapshot.Should().NotBeNull(); - latestSnapshot!.Data.ToObject().Should().BeEquivalentTo(snapshot); + latestSnapshot.ShouldNotBeNull(); + latestSnapshot!.Data.ToObject().ShouldBe(snapshot); } [Theory, AutoData] @@ -126,6 +131,6 @@ public async Task CreateTransactionalBatchAsync_ShouldCommitMultipleEvents(Guid // assert var result = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None); - result.Should().HaveCount(testEvents.Count); + result.Count.ShouldBe(testEvents.Count); } } diff --git a/tests/Papst.EventStore.MongoDB.Tests/Papst.EventStore.MongoDB.Tests.csproj b/tests/Papst.EventStore.MongoDB.Tests/Papst.EventStore.MongoDB.Tests.csproj index 97f7171..b77b2d1 100644 --- a/tests/Papst.EventStore.MongoDB.Tests/Papst.EventStore.MongoDB.Tests.csproj +++ b/tests/Papst.EventStore.MongoDB.Tests/Papst.EventStore.MongoDB.Tests.csproj @@ -14,7 +14,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/Papst.EventStore.Tests/AggregationContextDataTests.cs b/tests/Papst.EventStore.Tests/AggregationContextDataTests.cs index f4b3446..a3e5226 100644 --- a/tests/Papst.EventStore.Tests/AggregationContextDataTests.cs +++ b/tests/Papst.EventStore.Tests/AggregationContextDataTests.cs @@ -1,6 +1,6 @@ using System; using AutoFixture.Xunit2; -using FluentAssertions; +using Shouldly; using Xunit; namespace Papst.EventStore.Tests; @@ -22,11 +22,11 @@ public void TestAggregationContextDataInitialization(string key, ulong version, { var data = new AggregationContextData(key, version, validUntil, value); - data.Should().NotBeNull(); - data.Key.Should().Be(key); - data.Version.Should().Be(version); - data.ValidUntilVersion.Should().Be(validUntil); - data.Value.Should().Be(value); + data.ShouldNotBeNull(); + data.Key.ShouldBe(key); + data.Version.ShouldBe(version); + data.ValidUntilVersion.ShouldBe(validUntil); + data.Value.ShouldBe(value); } [Fact] @@ -35,7 +35,7 @@ public void TestAggregationContextDataEquality() var d1 = new AggregationContextData("key", 1UL, 5UL, "value"); var d2 = new AggregationContextData("key", 1UL, 5UL, "value"); - d1.Should().Be(d2); + d1.ShouldBe(d2); } [Fact] @@ -44,7 +44,7 @@ public void TestAggregationContextDataInequalityDifferentKey() var d1 = new AggregationContextData("k1", 1UL, 5UL, "value"); var d2 = new AggregationContextData("k2", 1UL, 5UL, "value"); - d1.Should().NotBe(d2); + d1.ShouldNotBe(d2); } [Fact] @@ -53,7 +53,7 @@ public void TestAggregationContextDataInequalityDifferentVersion() var d1 = new AggregationContextData("key", 1UL, 5UL, "value"); var d2 = new AggregationContextData("key", 2UL, 5UL, "value"); - d1.Should().NotBe(d2); + d1.ShouldNotBe(d2); } [Fact] @@ -65,11 +65,11 @@ public void SetAndGetAggregationData_WhenValid_ReturnsData() var data = ctx.GetAggregationData("key1"); - data.Should().NotBeNull(); - data!.Key.Should().Be("key1"); - data.Value.Should().Be("value1"); - data.Version.Should().Be(4UL); - data.ValidUntilVersion.Should().BeNull(); + data.ShouldNotBeNull(); + data!.Key.ShouldBe("key1"); + data.Value.ShouldBe("value1"); + data.Version.ShouldBe(4UL); + data.ValidUntilVersion.ShouldBeNull(); } [Fact] @@ -81,7 +81,7 @@ public void GetAggregationData_WithVersionGreaterThanCurrent_ReturnsNull() var data = ctx.GetAggregationData("k"); - data.Should().BeNull(); + data.ShouldBeNull(); } [Fact] @@ -93,7 +93,7 @@ public void GetAggregationData_ExpiredByValidUntil_ReturnsNull() var data = ctx.GetAggregationData("k2"); - data.Should().BeNull(); + data.ShouldBeNull(); } [Fact] @@ -109,7 +109,7 @@ public void GetAggregationData_IgnoreValidity_ReturnsDataEvenIfExpiredOrNew() var newer = ctx.GetAggregationData("newer", ignoreValidity: true); var expired = ctx.GetAggregationData("expired", ignoreValidity: true); - newer.Should().NotBeNull(); - expired.Should().NotBeNull(); + newer.ShouldNotBeNull(); + expired.ShouldNotBeNull(); } } diff --git a/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs index 77cdfc8..8be9c84 100644 --- a/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs +++ b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs @@ -1,8 +1,8 @@ #nullable enable using System; using System.Threading.Tasks; -using FluentAssertions; using Papst.EventStore.Aggregation; +using Shouldly; using Xunit; namespace Papst.EventStore.Tests; @@ -19,7 +19,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableStructHasValue() _sut.CallSetIfNotNull(v => result = v, value); - result.Should().Be(42); + result.ShouldBe(42); } [Fact] @@ -30,7 +30,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableStructIsNull() _sut.CallSetIfNotNull(v => result = v, value); - result.Should().Be(0); + result.ShouldBe(0); } [Fact] @@ -41,7 +41,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableDateTimeHasValue() _sut.CallSetIfNotNull(v => result = v, value); - result.Should().Be(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + result.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } [Fact] @@ -52,7 +52,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableDateTimeIsNull() _sut.CallSetIfNotNull(v => result = v, value); - result.Should().Be(default(DateTime)); + result.ShouldBe(default(DateTime)); } [Fact] @@ -64,7 +64,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableGuidHasValue() _sut.CallSetIfNotNull(v => result = v, value); - result.Should().Be(expected); + result.ShouldBe(expected); } [Fact] @@ -75,7 +75,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableGuidIsNull() _sut.CallSetIfNotNull(v => result = v, value); - result.Should().Be(Guid.Empty); + result.ShouldBe(Guid.Empty); } [Fact] @@ -86,7 +86,7 @@ public void Update_ShouldInvokeSetter_WhenNullableStructHasValue() _sut.CallUpdate(value, v => result = v); - result.Should().Be(42); + result.ShouldBe(42); } [Fact] @@ -97,7 +97,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableStructIsNull() _sut.CallUpdate(value, v => result = v); - result.Should().Be(0); + result.ShouldBe(0); } [Fact] @@ -108,7 +108,7 @@ public void Update_ShouldInvokeSetter_WhenNullableDateTimeHasValue() _sut.CallUpdate(value, v => result = v); - result.Should().Be(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + result.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } [Fact] @@ -119,7 +119,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableDateTimeIsNull() _sut.CallUpdate(value, v => result = v); - result.Should().Be(default(DateTime)); + result.ShouldBe(default(DateTime)); } [Fact] @@ -131,7 +131,7 @@ public void Update_ShouldInvokeSetter_WhenNullableGuidHasValue() _sut.CallUpdate(value, v => result = v); - result.Should().Be(expected); + result.ShouldBe(expected); } [Fact] @@ -142,7 +142,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableGuidIsNull() _sut.CallUpdate(value, v => result = v); - result.Should().Be(Guid.Empty); + result.ShouldBe(Guid.Empty); } private class TestEntity diff --git a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs index cf3f8f0..8f58b84 100644 --- a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs +++ b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using FluentAssertions; using Papst.EventStore.EventCatalog; using Papst.EventStore.Exceptions; +using Shouldly; using Xunit; namespace Papst.EventStore.Tests.EventCatalog; @@ -32,8 +32,8 @@ public async Task RegisterEvent_ShouldStoreEventForEntity() var entries = await provider.ListEvents(); - entries.Should().ContainSingle() - .Which.EventName.Should().Be("TestEvent"); + entries.Count().ShouldBe(1); + entries.Single().EventName.ShouldBe("TestEvent"); } [Fact] @@ -46,8 +46,8 @@ public async Task GetEntries_FilterByName_ReturnsMatching() var entries = await provider.ListEvents(name: "EventA"); - entries.Should().ContainSingle() - .Which.EventName.Should().Be("EventA"); + entries.Count().ShouldBe(1); + entries.Single().EventName.ShouldBe("EventA"); } [Fact] @@ -60,8 +60,8 @@ public async Task GetEntries_FilterByConstraints_ReturnsMatching() var entries = await provider.ListEvents(constraints: new[] { "admin" }); - entries.Should().ContainSingle() - .Which.EventName.Should().Be("EventX"); + entries.Count().ShouldBe(1); + entries.Single().EventName.ShouldBe("EventX"); } [Fact] @@ -75,8 +75,8 @@ public async Task GetEntries_FilterByNameAndConstraints_ReturnsMatching() var entries = await provider.ListEvents(name: "EventA", constraints: new[] { "admin" }); - entries.Should().ContainSingle() - .Which.EventName.Should().Be("EventA"); + entries.Count().ShouldBe(1); + entries.Single().EventName.ShouldBe("EventA"); } [Fact] @@ -88,7 +88,7 @@ public async Task GetEntries_ForUnknownEntity_ReturnsEmpty() var entries = await provider.ListEvents(); - entries.Should().BeEmpty(); + entries.ShouldBeEmpty(); } [Fact] @@ -101,11 +101,11 @@ public async Task GetDetails_ReturnsSchemaAndDescription() var details = await provider.GetEventDetails("DetailedEvent"); - details.Should().NotBeNull(); - details!.EventName.Should().Be("DetailedEvent"); - details.Description.Should().Be("Has a schema"); - details.Constraints.Should().BeEquivalentTo(new[] { "v1" }); - details.JsonSchema.Should().Be(schema); + details.ShouldNotBeNull(); + details!.EventName.ShouldBe("DetailedEvent"); + details.Description.ShouldBe("Has a schema"); + details.Constraints.ShouldBe(new[] { "v1" }); + details.JsonSchema.ShouldBe(schema); } [Fact] @@ -115,7 +115,7 @@ public async Task GetDetails_UnknownEvent_ReturnsNull() var details = await provider.GetEventDetails("NonExistent"); - details.Should().BeNull(); + details.ShouldBeNull(); } [Fact] @@ -131,12 +131,13 @@ public async Task GetDetails_LazySchemaEvaluatedOnAccess() registration.RegisterEvent("LazyEvent", null, null, lazySchema); - evaluated.Should().BeFalse("schema should not be evaluated at registration time"); + evaluated.ShouldBeFalse(); var details = await provider.GetEventDetails("LazyEvent"); - evaluated.Should().BeTrue("schema should be evaluated when details are requested"); - details!.JsonSchema.Should().Be("""{"type":"object"}"""); + evaluated.ShouldBeTrue(); + details.ShouldNotBeNull(); + details.JsonSchema.ShouldBe("""{"type":"object"}"""); } // --- EventCatalogProvider delegation tests --- @@ -150,8 +151,11 @@ public async Task ListEvents_DelegatesToRegistrations() var entries = await provider.ListEvents(); - entries.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new EventCatalogEntry("Evt1", "desc", null)); + entries.Count().ShouldBe(1); + var entry = entries.Single(); + entry.EventName.ShouldBe("Evt1"); + entry.Description.ShouldBe("desc"); + entry.Constraints.ShouldBeNull(); } [Fact] @@ -163,8 +167,11 @@ public async Task GetEventDetails_DelegatesToRegistrations() var details = await provider.GetEventDetails("Evt2"); - details.Should().NotBeNull(); - details.Should().BeEquivalentTo(new EventCatalogEventDetails("Evt2", "desc2", new[] { "c1" }, """{"schema":true}""")); + details.ShouldNotBeNull(); + details!.EventName.ShouldBe("Evt2"); + details.Description.ShouldBe("desc2"); + details.Constraints.ShouldBe(new[] { "c1" }); + details.JsonSchema.ShouldBe("""{"schema":true}"""); } [Fact] @@ -180,8 +187,8 @@ public async Task ListEvents_CombinesMultipleRegistrations() var entries = await provider.ListEvents(); - entries.Should().HaveCount(2); - entries.Select(e => e.EventName).Should().BeEquivalentTo("FromReg1", "FromReg2"); + entries.Count().ShouldBe(2); + entries.Select(e => e.EventName).OrderBy(name => name).ToArray().ShouldBe(new[] { "FromReg1", "FromReg2" }); } // --- Entity-scoped GetEventDetails tests --- @@ -197,13 +204,13 @@ public async Task GetEventDetails_EntityScoped_ReturnsDetailsForEntity() var testDetails = await provider.GetEventDetails("SharedEvent"); var otherDetails = await provider.GetEventDetails("SharedEvent"); - testDetails.Should().NotBeNull(); - testDetails!.Description.Should().Be("Test version"); - testDetails.JsonSchema.Should().Be("""{"entity":"test"}"""); + testDetails.ShouldNotBeNull(); + testDetails!.Description.ShouldBe("Test version"); + testDetails.JsonSchema.ShouldBe("""{"entity":"test"}"""); - otherDetails.Should().NotBeNull(); - otherDetails!.Description.Should().Be("Other version"); - otherDetails.JsonSchema.Should().Be("""{"entity":"other"}"""); + otherDetails.ShouldNotBeNull(); + otherDetails!.Description.ShouldBe("Other version"); + otherDetails.JsonSchema.ShouldBe("""{"entity":"other"}"""); } [Fact] @@ -215,7 +222,7 @@ public async Task GetEventDetails_EntityScoped_UnknownEntity_ReturnsNull() var details = await provider.GetEventDetails("SomeEvent"); - details.Should().BeNull(); + details.ShouldBeNull(); } [Fact] @@ -228,8 +235,8 @@ public async Task GetEventDetails_DuplicateEventNamesAcrossEntities_GlobalThrows Func act = async () => _ = await provider.GetEventDetails("DuplicateName"); - await act.Should().ThrowAsync() - .WithMessage("*DuplicateName*"); + var exception = await Should.ThrowAsync(act); + exception.Message.ShouldContain("DuplicateName"); } [Fact] @@ -245,7 +252,7 @@ public async Task GetEventDetails_DuplicateEventNamesAcrossRegistrations_GlobalT Func act = async () => _ = await provider.GetEventDetails("DuplicateName"); - await act.Should().ThrowAsync() - .WithMessage("*DuplicateName*"); + var exception = await Should.ThrowAsync(act); + exception.Message.ShouldContain("DuplicateName"); } } diff --git a/tests/Papst.EventStore.Tests/EventRegistrationTests.cs b/tests/Papst.EventStore.Tests/EventRegistrationTests.cs index f6735c8..d6e8653 100644 --- a/tests/Papst.EventStore.Tests/EventRegistrationTests.cs +++ b/tests/Papst.EventStore.Tests/EventRegistrationTests.cs @@ -1,8 +1,8 @@ using AutoFixture.Xunit2; -using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using Papst.EventStore.EventRegistration; +using Shouldly; using Xunit; namespace Papst.EventStore.Tests; @@ -20,8 +20,8 @@ public void RegistrationShouldReturnWriteName(string readName, string writeName) var eventreg = new EventRegistrationTypeProvider(loggerMock.Object, new[] { registration }); var resolved = eventreg.ResolveType(typeof(FooEvent)); - resolved.Should().NotBeNull(); - resolved.Should().Be(writeName); + resolved.ShouldNotBeNull(); + resolved.ShouldBe(writeName); } [Theory, AutoData] @@ -35,8 +35,8 @@ public void RegistrationShouldResolveReadName(string readName, string writeName) var eventreg = new EventRegistrationTypeProvider(loggerMock.Object, new[] { registration }); var resolved = eventreg.ResolveIdentifier(readName); - resolved.Should().NotBeNull(); - resolved.Should().Be(typeof(FooEvent)); + resolved.ShouldNotBeNull(); + resolved.ShouldBe(typeof(FooEvent)); } private record FooEvent(); diff --git a/tests/Papst.EventStore.Tests/EventStreamMetadataTests.cs b/tests/Papst.EventStore.Tests/EventStreamMetadataTests.cs index 28afe31..70e43af 100644 --- a/tests/Papst.EventStore.Tests/EventStreamMetadataTests.cs +++ b/tests/Papst.EventStore.Tests/EventStreamMetadataTests.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using AutoFixture.Xunit2; -using FluentAssertions; using Newtonsoft.Json; using Papst.EventStore.Documents; +using Shouldly; using Xunit; namespace Papst.EventStore.Tests; @@ -27,32 +27,42 @@ Dictionary add Additional = add }; - meta.Should().NotBeNull(); - meta.UserId.Should().Be(id); - meta.UserName.Should().Be(name); - meta.TenantId.Should().Be(tenant); - meta.Comment.Should().Be(comment); - meta.Additional.Should().BeEquivalentTo(add); + meta.ShouldNotBeNull(); + meta.UserId.ShouldBe(id); + meta.UserName.ShouldBe(name); + meta.TenantId.ShouldBe(tenant); + meta.Comment.ShouldBe(comment); + meta.Additional.ShouldNotBeNull(); + meta.Additional.Count.ShouldBe(add.Count); + foreach (var entry in add) + { + meta.Additional[entry.Key].ShouldBe(entry.Value); + } } [Theory, AutoData] public void TestEventMetaDataSerialization(EventStreamMetaData doc) { - doc.Should().NotBeNull(); + doc.ShouldNotBeNull(); string serialized = JsonConvert.SerializeObject(doc); - serialized.Should().NotBeNull().And.NotBeEmpty().And.StartWith("{"); + serialized.ShouldNotBeNullOrEmpty(); + serialized.ShouldStartWith("{"); } [Theory, AutoData] public void TestEventMetaDataDeserialization(EventStreamMetaData doc) { - doc.Should().NotBeNull(); + doc.ShouldNotBeNull(); + + var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(doc)); - JsonConvert.DeserializeObject(JsonConvert.SerializeObject(doc)) - .Should() - .NotBeNull() - .And.BeEquivalentTo(doc); + deserialized.ShouldNotBeNull(); + deserialized.UserId.ShouldBe(doc.UserId); + deserialized.UserName.ShouldBe(doc.UserName); + deserialized.TenantId.ShouldBe(doc.TenantId); + deserialized.Comment.ShouldBe(doc.Comment); + deserialized.Additional.ShouldBe(doc.Additional); } } diff --git a/tests/Papst.EventStore.Tests/Papst.EventStore.Tests.csproj b/tests/Papst.EventStore.Tests/Papst.EventStore.Tests.csproj index 720283e..9e9f0fb 100644 --- a/tests/Papst.EventStore.Tests/Papst.EventStore.Tests.csproj +++ b/tests/Papst.EventStore.Tests/Papst.EventStore.Tests.csproj @@ -10,7 +10,7 @@ - + @@ -38,4 +38,4 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - \ No newline at end of file + diff --git a/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStoreTests.cs b/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStoreTests.cs index 49d8af2..c559ffc 100644 --- a/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStoreTests.cs +++ b/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStoreTests.cs @@ -1,7 +1,7 @@ using AutoFixture.Xunit2; -using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Papst.EventStore; +using Shouldly; namespace Papst.EventsStore.InMemory.Tests.IntegrationTests; @@ -22,8 +22,8 @@ public async Task CreateAsync_ShouldCreateStream(Guid streamId) // assert var stream = await eventStore.GetAsync(streamId, CancellationToken.None); - stream.StreamId.Should().Be(streamId); - stream.Version.Should().Be(0); + stream.StreamId.ShouldBe(streamId); + stream.Version.ShouldBe(0UL); } diff --git a/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStreamTests.cs b/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStreamTests.cs index 660dac1..69267f8 100644 --- a/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStreamTests.cs +++ b/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStreamTests.cs @@ -1,8 +1,8 @@ using AutoFixture.Xunit2; -using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Papst.EventsStore.InMemory.Tests.IntegrationTests.Events; using Papst.EventStore; +using Shouldly; namespace Papst.EventsStore.InMemory.Tests.IntegrationTests; @@ -24,7 +24,7 @@ public async Task ListAsync_ShouldReturnEmpty(Guid streamId) var events = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None); // assert - events.Should().BeEmpty(); + events.ShouldBeEmpty(); } [Theory, AutoData] @@ -43,8 +43,12 @@ public async Task ListAsync_ShouldReturnEntries(List events, Guid str var result = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None); // assert - result.Should().HaveCount(events.Count); - result.Select(e => e.Data.ToObject()).Should().BeEquivalentTo(events); + result.Count.ShouldBe(events.Count); + result.Select(e => e.Data.ToObject()) + .Where(e => e is not null) + .Select(e => e!) + .ToList() + .ShouldBe(events); } } diff --git a/tests/Papst.EventsStore.InMemory.Tests/Papst.EventsStore.InMemory.Tests.csproj b/tests/Papst.EventsStore.InMemory.Tests/Papst.EventsStore.InMemory.Tests.csproj index cdabaa5..95080eb 100644 --- a/tests/Papst.EventsStore.InMemory.Tests/Papst.EventsStore.InMemory.Tests.csproj +++ b/tests/Papst.EventsStore.InMemory.Tests/Papst.EventsStore.InMemory.Tests.csproj @@ -15,7 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + From ae1f13cce135cbe64c640b18005eb82cb07b9c8e Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Sat, 11 Apr 2026 16:59:29 +0200 Subject: [PATCH 5/5] Update Version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index eabde83..8a5474d 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "5.4", + "version": "6.0", "publicReleaseRefSpec": [ "^refs/heads/main" ],