diff --git a/Sample/EventsVersioning/Talk/EventsVersioning.Talk.sln b/Sample/EventsVersioning/Talk/EventsVersioning.Talk.sln index 5dba1611..150eb4d7 100644 --- a/Sample/EventsVersioning/Talk/EventsVersioning.Talk.sln +++ b/Sample/EventsVersioning/Talk/EventsVersioning.Talk.sln @@ -2,6 +2,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotelManagement", "HotelManagement\HotelManagement.csproj", "{8B5F91C2-572B-4B2D-B67B-3EA98584888E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotelManagement.Tests", "HotelManagement.Tests\HotelManagement.Tests.csproj", "{79C84CFC-D0C4-4341-B262-92A99A372242}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +14,9 @@ Global {8B5F91C2-572B-4B2D-B67B-3EA98584888E}.Debug|Any CPU.Build.0 = Debug|Any CPU {8B5F91C2-572B-4B2D-B67B-3EA98584888E}.Release|Any CPU.ActiveCfg = Release|Any CPU {8B5F91C2-572B-4B2D-B67B-3EA98584888E}.Release|Any CPU.Build.0 = Release|Any CPU + {79C84CFC-D0C4-4341-B262-92A99A372242}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79C84CFC-D0C4-4341-B262-92A99A372242}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79C84CFC-D0C4-4341-B262-92A99A372242}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79C84CFC-D0C4-4341-B262-92A99A372242}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Downcasters/ChangedStructure.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Downcasters/ChangedStructure.cs new file mode 100644 index 00000000..496e46f0 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Downcasters/ChangedStructure.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using FluentAssertions; +using HotelManagement.GuestStayAccounts; + +namespace HotelManagement.Tests.Downcasters; + +using V1 = GuestStayAccountEvent; + +public class ChangedStructure +{ + public record Money( + decimal Amount, + string Currency = "CHF" + ); + + public record PaymentRecorded( + string GuestStayAccountId, + Money Amount, + DateTimeOffset RecordedAt + ); + + public static V1.PaymentRecorded Downcast( + PaymentRecorded newEvent + ) + { + return new V1.PaymentRecorded( + newEvent.GuestStayAccountId, + newEvent.Amount.Amount, + newEvent.RecordedAt + ); + } + + public static V1.PaymentRecorded Downcast( + string newEventJson + ) + { + var newEvent = JsonDocument.Parse(newEventJson).RootElement; + + return new V1.PaymentRecorded( + newEvent.GetProperty("GuestStayAccountId").GetString()!, + newEvent.GetProperty("Amount").GetProperty("Amount").GetDecimal(), + newEvent.GetProperty("RecordedAt").GetDateTimeOffset() + ); + } + + [Fact] + public void DowncastObjects_Should_BeForwardCompatible() + { + // Given + var newEvent = new PaymentRecorded( + Guid.NewGuid().ToString(), + new Money((decimal)Random.Shared.NextDouble(), "USD"), + DateTimeOffset.Now + ); + + // When + var @event = Downcast(newEvent); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(newEvent.GuestStayAccountId); + @event.Amount.Should().Be(newEvent.Amount.Amount); + } + + [Fact] + public void DowncastJson_Should_BeForwardCompatible() + { + // Given + var newEvent = new PaymentRecorded( + Guid.NewGuid().ToString(), + new Money((decimal)Random.Shared.NextDouble(), "USD"), + DateTimeOffset.Now + ); + // When + var @event = Downcast( + JsonSerializer.Serialize(newEvent) + ); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(newEvent.GuestStayAccountId); + @event.Amount.Should().Be(newEvent.Amount.Amount); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ExplicitSerializationTests.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ExplicitSerializationTests.cs new file mode 100644 index 00000000..a0a70c36 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ExplicitSerializationTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using FluentAssertions; + +namespace HotelManagement.Tests.ExplicitSerialization; + +using static ShoppingCartEvent; + +public class ExplicitSerializationTests +{ + [Fact] + public void ShouldSerializeAndDeserializeEvents() + { + var shoppingCartId = ShoppingCartId.New(); + var clientId = ClientId.New(); + + var tShirt = ProductId.New(); + var tShirtPrice = Price.Parse(new Money(Amount.Parse(33), Currency.PLN)); + + var shoes = ProductId.New(); + var shoesPrice = Price.Parse(new Money(Amount.Parse(77), Currency.PLN)); + + var events = new ShoppingCartEvent[] + { + new ShoppingCartOpened( + shoppingCartId, + clientId + ), + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(tShirt, Quantity.Parse(5), tShirtPrice) + ), + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(shoes, Quantity.Parse(1), shoesPrice) + ), + new ProductItemRemovedFromShoppingCart( + shoppingCartId, + new PricedProductItem(tShirt, Quantity.Parse(3), tShirtPrice) + ), + new ShoppingCartConfirmed( + shoppingCartId, + LocalDateTime.Parse(DateTimeOffset.UtcNow) + ) + }; + + var serde = new ShoppingCartEventsSerde(); + + var serializedEvents = events.Select(serde.Serialize); + + var deserializedEvents = serializedEvents.Select(e => + serde.Deserialize(e.EventType, JsonDocument.Parse(e.Data.ToJsonString())) + ).ToArray(); + + for (var i = 0; i < deserializedEvents.Length; i++) + { + deserializedEvents[i].Equals(events[i]).Should().BeTrue(); + } + } + + + [Fact] + public void ShouldGetCurrentShoppingCartState() + { + var shoppingCartId = ShoppingCartId.New(); + var clientId = ClientId.New(); + + var tShirt = ProductId.New(); + var tShirtPrice = Price.Parse(new Money(Amount.Parse(33), Currency.PLN)); + + var shoes = ProductId.New(); + var shoesPrice = Price.Parse(new Money(Amount.Parse(77), Currency.PLN)); + + var events = new ShoppingCartEvent[] + { + new ShoppingCartOpened( + shoppingCartId, + clientId + ), + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(tShirt, Quantity.Parse(5), tShirtPrice) + ), + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(shoes, Quantity.Parse(1), shoesPrice) + ), + new ProductItemRemovedFromShoppingCart( + shoppingCartId, + new PricedProductItem(tShirt, Quantity.Parse(3), tShirtPrice) + ), + new ShoppingCartConfirmed( + shoppingCartId, + LocalDateTime.Parse(DateTimeOffset.UtcNow) + ) + }; + + var shoppingCart = events.Aggregate(ShoppingCart.Default, ShoppingCart.Evolve); + + shoppingCart.Id.Should().Be(shoppingCartId); + shoppingCart.ClientId.Should().Be(clientId); + shoppingCart.Status.Should().Be(ShoppingCartStatus.Confirmed); + shoppingCart.ProductItems.Should().HaveCount(2); + shoppingCart.ProductItems.Keys.Should().Contain(new[] { tShirt, shoes }); + shoppingCart.ProductItems[tShirt].Should().Be(Quantity.Parse(2)); + shoppingCart.ProductItems[shoes].Should().Be(Quantity.Parse(1)); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ShoppingCart.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ShoppingCart.cs new file mode 100644 index 00000000..8d0f9b46 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ShoppingCart.cs @@ -0,0 +1,407 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace HotelManagement.Tests.ExplicitSerialization; + +using static ShoppingCartEvent; + +public abstract class StronglyTypedValue(T value): IEquatable> + where T : notnull +{ + public T Value { get; } = value; + + public override string ToString() => Value.ToString()!; + + public bool Equals(StronglyTypedValue? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return EqualityComparer.Default.Equals(Value, other.Value); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((StronglyTypedValue)obj); + } + + public override int GetHashCode() => + EqualityComparer.Default.GetHashCode(Value); + + public static bool operator ==(StronglyTypedValue? left, StronglyTypedValue? right) => + Equals(left, right); + + public static bool operator !=(StronglyTypedValue? left, StronglyTypedValue? right) => + !Equals(left, right); +} + +public class ClientId: StronglyTypedValue +{ + private ClientId(Guid value): base(value) { } + + public static readonly ClientId Unknown = new(Guid.Empty); + + public static ClientId New() => new(Guid.NewGuid()); + + public static ClientId Parse(string? value) + { + if (!Guid.TryParse(value, out var guidValue) || guidValue == Guid.Empty) + throw new ArgumentOutOfRangeException(nameof(value)); + + return new ClientId(guidValue); + } +} + +public class ProductId: StronglyTypedValue +{ + private ProductId(Guid value): base(value) { } + + public static readonly ProductId Unknown = new(Guid.Empty); + + public static ProductId New() => new(Guid.NewGuid()); + + public static ProductId Parse(string? value) + { + if (!Guid.TryParse(value, out var guidValue) || guidValue == Guid.Empty) + throw new ArgumentOutOfRangeException(nameof(value)); + + return new ProductId(guidValue); + } +} + +public class ShoppingCartId: StronglyTypedValue +{ + private ShoppingCartId(Guid value): base(value) + { + } + + public static readonly ShoppingCartId Unknown = new(Guid.Empty); + + public static ShoppingCartId New() => new(Guid.NewGuid()); + + public static ShoppingCartId Parse(string? value) + { + if (!Guid.TryParse(value, out var guidValue) || guidValue == Guid.Empty) + throw new ArgumentOutOfRangeException(nameof(value)); + + return new ShoppingCartId(guidValue); + } +} + +public enum Currency +{ + USD, + EUR, + PLN +} + +public class Amount: StronglyTypedValue, IComparable +{ + private Amount(int value): base(value) { } + public bool IsPositive => Value > 0; + + public int CompareTo(Amount? other) => Value.CompareTo(other?.Value); + + public static Amount Parse(int value) => new(value); +} + +public class Quantity: StronglyTypedValue, IComparable, IComparable +{ + private Quantity(uint value): base(value) { } + + public int CompareTo(Quantity? other) => Value.CompareTo(other?.Value); + public int CompareTo(int other) => Value.CompareTo(other); + + public static Quantity operator +(Quantity a) => a; + + public static Quantity operator -(Quantity _) => throw new InvalidOperationException(); + + public static Quantity operator +(Quantity a, Quantity b) => + new(a.Value + b.Value); + + public static Quantity operator -(Quantity a, Quantity b) => + new(a.Value - b.Value); + + public static bool operator >(Quantity a, Quantity b) + => a.Value > b.Value; + + public static bool operator >=(Quantity a, Quantity b) + => a.Value >= b.Value; + + public static bool operator <(Quantity a, Quantity b) + => a.Value < b.Value; + + public static bool operator <=(Quantity a, Quantity b) + => a.Value <= b.Value; + + public static Quantity Parse(uint value) => new(value); +} + +public class LocalDateTime: StronglyTypedValue, IComparable +{ + private LocalDateTime(DateTimeOffset value): base(value) + { + } + + public int CompareTo(LocalDateTime? other) => other != null ? Value.CompareTo(other.Value) : -1; + + + public static LocalDateTime Parse(DateTimeOffset value) => new(value); +} + +public record Money( + Amount Amount, + Currency Currency +); + +public class Price(Money value): StronglyTypedValue(value) +{ + public static Price Parse(Money value) + { + if (!value.Amount.IsPositive) + throw new ArgumentOutOfRangeException(nameof(value), "Price cannot be negative"); + + return new Price(value); + } +} + +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + ShoppingCartId ShoppingCartId, + ClientId ClientId + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + ShoppingCartId ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + ShoppingCartId ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + ShoppingCartId ShoppingCartId, + LocalDateTime ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + ShoppingCartId ShoppingCartId, + LocalDateTime CanceledAt + ): ShoppingCartEvent; +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} + +public record PricedProductItem( + ProductId ProductId, + Quantity Quantity, + Price UnitPrice +); + +public record ShoppingCart( + ShoppingCartId Id, + ClientId ClientId, + ShoppingCartStatus Status, + Dictionary ProductItems +) +{ + public static ShoppingCart Evolve(ShoppingCart entity, object @event) => + @event switch + { + ShoppingCartOpened (var cartId, var clientId) => + new ShoppingCart(cartId, clientId, ShoppingCartStatus.Pending, new Dictionary()), + + ProductItemAddedToShoppingCart (_, var productItem) => + entity with { ProductItems = entity.ProductItems.Add(productItem) }, + + ProductItemRemovedFromShoppingCart (_, var productItem) => + entity with { ProductItems = entity.ProductItems.Remove(productItem) }, + + ShoppingCartConfirmed (_, var confirmedAt) => + entity with { Status = ShoppingCartStatus.Confirmed }, + + ShoppingCartCanceled (_, var canceledAt) => + entity with { Status = ShoppingCartStatus.Canceled }, + _ => entity + }; + + public static ShoppingCart Default => + new(ShoppingCartId.Unknown, ClientId.Unknown, default, new Dictionary()); +} + +public static class ProductItemsExtensions +{ + public static Dictionary Add(this Dictionary productItems, + PricedProductItem productItem) => + productItems + .Union(new[] { new KeyValuePair(productItem.ProductId, productItem.Quantity) }) + .GroupBy(ks => ks.Key) + .ToDictionary(ks => ks.Key, ps => Quantity.Parse((uint)ps.Sum(x => x.Value.Value))); + + public static Dictionary + Remove(this Dictionary productItems, PricedProductItem productItem) => + productItems + .Select(p => + p.Key == productItem.ProductId + ? new KeyValuePair(p.Key, + Quantity.Parse(p.Value.Value - productItem.Quantity.Value)) + : p) + .Where(p => p.Value > Quantity.Parse(0)) + .ToDictionary(ks => ks.Key, ps => ps.Value); +} + +public class ShoppingCartEventsSerde +{ + public (string EventType, JsonObject Data) Serialize(ShoppingCartEvent @event) => + @event switch + { + ShoppingCartOpened e => + ("shopping_cart_opened", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("clientId", e.ClientId.ToJson() + ) + ) + ), + ProductItemAddedToShoppingCart e => + ("product_item_added_to_shopping_cart", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("productItem", e.ProductItem.ToJson()) + ) + ), + ProductItemRemovedFromShoppingCart e => + ("product_item_removed_from_shopping_cart", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("productItem", e.ProductItem.ToJson()) + ) + ), + ShoppingCartConfirmed e => + ("shopping_cart_confirmed", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("confirmedAt", e.ConfirmedAt.ToJson()) + ) + ), + ShoppingCartCanceled e => + ("shopping_cart_canceled", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("canceledAt", e.CanceledAt.ToJson()) + ) + ), + _ => throw new InvalidOperationException() + }; + + public ShoppingCartEvent Deserialize(string eventType, JsonDocument document) + { + var data = document.RootElement; + + return eventType switch + { + "shopping_cart_opened" => + new ShoppingCartOpened( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("clientId").ToClientId() + ), + "product_item_added_to_shopping_cart" => + new ProductItemAddedToShoppingCart( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("productItem").ToPricedProductItem() + ), + "product_item_removed_from_shopping_cart" => + new ProductItemRemovedFromShoppingCart( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("productItem").ToPricedProductItem() + ), + "shopping_cart_confirmed" => + new ShoppingCartConfirmed( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("confirmedAt").ToLocalDateTime() + ), + "shopping_cart_canceled" => + new ShoppingCartCanceled( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("canceledAt").ToLocalDateTime() + ), + _ => throw new InvalidOperationException() + }; + } +} + +public static class Json +{ + public static JsonObject Object(params KeyValuePair[] nodes) => new(nodes); + public static KeyValuePair Node(string key, JsonNode? node) => new(key, node); + + public static JsonNode ToJson(this ShoppingCartId value) => value.Value; + public static JsonNode ToJson(this ProductId value) => value.Value; + public static JsonNode ToJson(this ClientId value) => value.Value; + public static JsonNode ToJson(this Amount value) => value.Value; + public static JsonNode ToJson(this Quantity value) => value.Value; + public static JsonNode ToJson(this LocalDateTime value) => value.Value; + + public static JsonObject ToJson(this Money value) => + Object( + Node("amount", value.Amount.ToJson()), + Node("currency", value.Currency.ToString()) + ); + + public static JsonObject ToJson(this Price value) => value.Value.ToJson(); + + public static JsonObject ToJson(this PricedProductItem value) => + Object( + Node("productId", value.ProductId.ToJson()), + Node("quantity", value.Quantity.ToJson()), + Node("unitPrice", value.UnitPrice.ToJson()) + ); + + public static ShoppingCartId ToShoppingCartId(this JsonElement value) => + ShoppingCartId.Parse(value.GetString()); + + public static ProductId ToProductId(this JsonElement value) => + ProductId.Parse(value.GetString()); + + public static ClientId ToClientId(this JsonElement value) => + ClientId.Parse(value.GetString()); + + public static Currency ToCurrency(this JsonElement value) => + Enum.Parse(value.GetString() ?? throw new ArgumentOutOfRangeException()); + + public static Amount ToAmount(this JsonElement value) => + Amount.Parse(value.GetInt32()); + + public static Quantity ToQuantity(this JsonElement value) => + Quantity.Parse(value.GetUInt32()); + + public static Money ToMoney(this JsonElement value) => + new( + value.GetProperty("amount").ToAmount(), + value.GetProperty("currency").ToCurrency() + ); + + public static LocalDateTime ToLocalDateTime(this JsonElement value) => + LocalDateTime.Parse(DateTimeOffset.Parse(value.GetString() ?? throw new ArgumentOutOfRangeException())); + + public static Price ToPrice(this JsonElement value) => new(value.ToMoney()); + + public static PricedProductItem ToPricedProductItem(this JsonElement value) => + new( + value.GetProperty("productId").ToProductId(), + value.GetProperty("quantity").ToQuantity(), + value.GetProperty("unitPrice").ToPrice() + ); +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/HotelManagement.Tests.csproj b/Sample/EventsVersioning/Talk/HotelManagement.Tests/HotelManagement.Tests.csproj new file mode 100644 index 00000000..8bd799c2 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/HotelManagement.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewNotRequiredProperty.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewNotRequiredProperty.cs new file mode 100644 index 00000000..8d734886 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewNotRequiredProperty.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts.GuestStayAccountEvent; + +namespace HotelManagement.Tests.SimpleMappings; + +public class NewNotRequiredProperty +{ + public record PaymentRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset RecordedAt, + string? ClerkId = null + ); + + [Fact] + public void Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var json = JsonSerializer.Serialize(oldEvent); + + // When + var @event = JsonSerializer.Deserialize(json); + + @event.Should().NotBeNull(); + @event!.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.ClerkId.Should().BeNull(); + } + + [Fact] + public void Should_BeBackwardCompatible() + { + // Given + var @event = new PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now, + Guid.NewGuid().ToString() + ); + var json = JsonSerializer.Serialize(@event); + + // When + var oldEvent = JsonSerializer.Deserialize(json); + + oldEvent.Should().NotBeNull(); + oldEvent!.GuestStayAccountId.Should().Be(@event.GuestStayAccountId); + oldEvent.Amount.Should().Be(@event.Amount); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewRequiredProperty.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewRequiredProperty.cs new file mode 100644 index 00000000..36703169 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewRequiredProperty.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts.GuestStayAccountEvent; + +namespace HotelManagement.Tests.SimpleMappings; + +public class NewRequiredProperty +{ + public record PaymentRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset RecordedAt, + string Currency = PaymentRecorded.DefaultCurrency + ) + { + public const string DefaultCurrency = "CHF"; + } + + [Fact] + public void Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var json = JsonSerializer.Serialize(oldEvent); + + // When + var @event = JsonSerializer.Deserialize(json); + + @event.Should().NotBeNull(); + @event!.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.Currency.Should().Be(PaymentRecorded.DefaultCurrency); + } + + [Fact] + public void Should_BeBackwardCompatible() + { + // Given + var @event = new PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now, + Guid.NewGuid().ToString() + ); + var json = JsonSerializer.Serialize(@event); + + // When + var oldEvent = JsonSerializer.Deserialize(json); + + oldEvent.Should().NotBeNull(); + oldEvent!.GuestStayAccountId.Should().Be(@event.GuestStayAccountId); + oldEvent.Amount.Should().Be(@event.Amount); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/RenamedProperty.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/RenamedProperty.cs new file mode 100644 index 00000000..1689b3a5 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/RenamedProperty.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts.GuestStayAccountEvent; + +namespace HotelManagement.Tests.SimpleMappings; + +public class RenamedProperty +{ + public record PaymentRecorded( + [property: JsonPropertyName("GuestStayAccountId")] + string AccountId, + decimal Amount, + DateTimeOffset RecordedAt, + string Currency = PaymentRecorded.DefaultCurrency + ) + { + public const string DefaultCurrency = "CHF"; + } + + [Fact] + public void Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var json = JsonSerializer.Serialize(oldEvent); + + // When + var @event = JsonSerializer.Deserialize(json); + + @event.Should().NotBeNull(); + @event!.AccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.Currency.Should().Be(PaymentRecorded.DefaultCurrency); + } + + [Fact] + public void Should_BeBackwardCompatible() + { + // Given + var @event = new PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now, + Guid.NewGuid().ToString() + ); + var json = JsonSerializer.Serialize(@event); + + // When + var oldEvent = JsonSerializer.Deserialize(json); + + oldEvent.Should().NotBeNull(); + oldEvent!.GuestStayAccountId.Should().Be(@event.AccountId); + oldEvent.Amount.Should().Be(@event.Amount); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithCompleteData_IsCompatible.verified.txt b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithCompleteData_IsCompatible.verified.txt new file mode 100644 index 00000000..bb48f461 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithCompleteData_IsCompatible.verified.txt @@ -0,0 +1,5 @@ +{ + ShoppingCartId: Guid_1, + ClientId: anonymised, + ConfirmedAt: DateTimeOffset_1 +} \ No newline at end of file diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithOnlyRequiredData_IsCompatible.verified.txt b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithOnlyRequiredData_IsCompatible.verified.txt new file mode 100644 index 00000000..365b1827 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithOnlyRequiredData_IsCompatible.verified.txt @@ -0,0 +1,4 @@ +{ + ShoppingCartId: Guid_1, + ConfirmedAt: DateTimeOffset_1 +} \ No newline at end of file diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.cs new file mode 100644 index 00000000..a5cbf760 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; + +namespace EventsVersioning.Tests.SnapshotTesting; + +public class EventsSnapshotTests +{ + private record ShoppingCartConfirmed( + Guid ShoppingCartId, + string? ClientId, + DateTimeOffset ConfirmedAt + ); + + [Fact] + public Task ShoppingCartConfirmed_WithCompleteData_IsCompatible() + { + var @event = new ShoppingCartConfirmed(Guid.NewGuid(), "Oskar Dudycz", DateTimeOffset.UtcNow); + return Verify(@event); + } + + [Fact] + public Task ShoppingCartConfirmed_WithOnlyRequiredData_IsCompatible() + { + var @event = new ShoppingCartConfirmed(Guid.NewGuid(), null, DateTimeOffset.UtcNow); + return Verify(@event); + } +} +// note this is optional, if you really need to +// This is just showing that you can +public static class StaticSettingsUsage +{ + [ModuleInitializer] + public static void Initialize() => + VerifierSettings.AddScrubber(text => text.Replace("Oskar Dudycz", "anonymised")); +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.cs new file mode 100644 index 00000000..20cf8668 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.cs @@ -0,0 +1,15 @@ +using HotelManagement.GuestStayAccounts; +using PublicApiGenerator; + +namespace EventsVersioning.Tests.SnapshotTesting; + +public class PackageSnapshotTests +{ + [Fact(Skip = "not now, my friend")] + public Task my_assembly_has_no_public_api_changes() + { + var publicApi = typeof(GuestStayAccountEvent).Assembly.GeneratePublicApi(); + + return Verify(publicApi); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.received.txt b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.received.txt new file mode 100644 index 00000000..24deb21d --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.received.txt @@ -0,0 +1,169 @@ +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] +namespace HotelManagement.EventStore +{ + public class CommandHandler + where TEvent : notnull + { + public CommandHandler(HotelManagement.EventStore.IEventStore eventStore, System.Func evolve, System.Func getInitial) { } + public System.Threading.Tasks.Task GetAndUpdate(System.Guid id, System.Func handle, System.Threading.CancellationToken ct) { } + } + public class EventMetadata : System.IEquatable + { + public EventMetadata(System.Guid CorrelationId) { } + public System.Guid CorrelationId { get; init; } + } + public class EventSerializer + { + public EventSerializer(HotelManagement.EventStore.EventTypeMapping mapping, HotelManagement.EventStore.EventTransformations transformations, HotelManagement.EventStore.StreamTransformations? streamTransformations = null) { } + public System.Collections.Generic.List Deserialize(System.Collections.Generic.List events) { } + public object? Deserialize(string eventTypeName, string json) { } + } + public class EventTransformations + { + public EventTransformations() { } + public HotelManagement.EventStore.EventTransformations Register(string eventTypeName, System.Func transformJson) + where TEvent : notnull { } + public HotelManagement.EventStore.EventTransformations Register(string eventTypeName, System.Func transformEvent) + where TOldEvent : notnull + where TEvent : notnull { } + public bool TryTransform(string eventTypeName, string json, out object? result) { } + } + public class EventTypeMapping + { + public EventTypeMapping() { } + public HotelManagement.EventStore.EventTypeMapping CustomMap(System.Type eventType, params string[] eventTypeNames) { } + public HotelManagement.EventStore.EventTypeMapping CustomMap(params string[] eventTypeNames) { } + public string ToName(System.Type eventType) { } + public string ToName() { } + public System.Type? ToType(string eventTypeName) { } + } + public interface IEventStore + { + System.Threading.Tasks.ValueTask AppendToStream(System.Guid streamId, System.Collections.Generic.IEnumerable newEvents, System.Threading.CancellationToken ct = default); + System.Threading.Tasks.ValueTask ReadStream(System.Guid streamId, System.Threading.CancellationToken ct = default) + where TEvent : notnull; + } + public class InMemoryEventStore : HotelManagement.EventStore.IEventStore + { + public InMemoryEventStore() { } + public System.Threading.Tasks.ValueTask AppendToStream(System.Guid streamId, System.Collections.Generic.IEnumerable newEvents, System.Threading.CancellationToken _ = default) { } + public System.Threading.Tasks.ValueTask ReadStream(System.Guid streamId, System.Threading.CancellationToken _ = default) + where TEvent : notnull { } + } + public class SerializedEvent : System.IEquatable + { + public SerializedEvent(string EventType, string Data, string MetaData = "") { } + public string Data { get; init; } + public string EventType { get; init; } + public string MetaData { get; init; } + } + public class StreamTransformations + { + public StreamTransformations() { } + public HotelManagement.EventStore.StreamTransformations Register(System.Func, System.Collections.Generic.List> transformJson) { } + public System.Collections.Generic.List Transform(System.Collections.Generic.List events) { } + } +} +namespace HotelManagement.GuestStayAccounts +{ + public class GuestStayAccount : System.IEquatable + { + public static readonly HotelManagement.GuestStayAccounts.GuestStayAccount Initial; + public GuestStayAccount(string Id, [System.Runtime.CompilerServices.DecimalConstant(0, 0, 0u, 0u, 0u)] decimal Balance, HotelManagement.GuestStayAccounts.GuestStayAccountStatus Status = 1) { } + public bool IsSettled { get; } + public decimal Balance { get; init; } + public string Id { get; init; } + public HotelManagement.GuestStayAccounts.GuestStayAccountStatus Status { get; init; } + public HotelManagement.GuestStayAccounts.GuestStayAccount Evolve(HotelManagement.GuestStayAccounts.GuestStayAccountEvent @event) { } + } + public abstract class GuestStayAccountCommand : System.IEquatable + { + public class CheckIn : HotelManagement.GuestStayAccounts.GuestStayAccountCommand, System.IEquatable + { + public CheckIn(string ClerkId, string GuestStayId, string RoomId, System.DateTimeOffset Now) { } + public string ClerkId { get; init; } + public string GuestStayId { get; init; } + public System.DateTimeOffset Now { get; init; } + public string RoomId { get; init; } + } + public class CheckOut : HotelManagement.GuestStayAccounts.GuestStayAccountCommand, System.IEquatable + { + public CheckOut(string ClerkId, string GuestStayAccountId, System.DateTimeOffset Now) { } + public string ClerkId { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset Now { get; init; } + } + public class RecordCharge : HotelManagement.GuestStayAccounts.GuestStayAccountCommand, System.IEquatable + { + public RecordCharge(string GuestStayAccountId, decimal Amount, System.DateTimeOffset Now) { } + public decimal Amount { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset Now { get; init; } + } + public class RecordPayment : HotelManagement.GuestStayAccounts.GuestStayAccountCommand, System.IEquatable + { + public RecordPayment(string GuestStayAccountId, decimal Amount, System.DateTimeOffset Now) { } + public decimal Amount { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset Now { get; init; } + } + } + public static class GuestStayAccountDecider + { + public static HotelManagement.GuestStayAccounts.GuestStayAccountEvent.GuestCheckedIn CheckIn(HotelManagement.GuestStayAccounts.GuestStayAccountCommand.CheckIn command, HotelManagement.GuestStayAccounts.GuestStayAccount state) { } + public static HotelManagement.GuestStayAccounts.GuestStayAccountEvent CheckOut(HotelManagement.GuestStayAccounts.GuestStayAccountCommand.CheckOut command, HotelManagement.GuestStayAccounts.GuestStayAccount state) { } + public static HotelManagement.GuestStayAccounts.GuestStayAccountEvent.ChargeRecorded RecordCharge(HotelManagement.GuestStayAccounts.GuestStayAccountCommand.RecordCharge command, HotelManagement.GuestStayAccounts.GuestStayAccount state) { } + public static HotelManagement.GuestStayAccounts.GuestStayAccountEvent.PaymentRecorded RecordPayment(HotelManagement.GuestStayAccounts.GuestStayAccountCommand.RecordPayment command, HotelManagement.GuestStayAccounts.GuestStayAccount state) { } + } + public abstract class GuestStayAccountEvent : System.IEquatable + { + public class ChargeRecorded : HotelManagement.GuestStayAccounts.GuestStayAccountEvent, System.IEquatable + { + public ChargeRecorded(string GuestStayAccountId, decimal Amount, System.DateTimeOffset RecordedAt) { } + public decimal Amount { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset RecordedAt { get; init; } + } + public class GuestCheckedIn : HotelManagement.GuestStayAccounts.GuestStayAccountEvent, System.IEquatable + { + public GuestCheckedIn(string GuestStayAccountId, string GuestStayId, string RoomId, string ClerkId, System.DateTimeOffset CheckedInAt) { } + public System.DateTimeOffset CheckedInAt { get; init; } + public string ClerkId { get; init; } + public string GuestStayAccountId { get; init; } + public string GuestStayId { get; init; } + public string RoomId { get; init; } + } + public class GuestCheckedOut : HotelManagement.GuestStayAccounts.GuestStayAccountEvent, System.IEquatable + { + public GuestCheckedOut(string GuestStayAccountId, string ClerkId, System.DateTimeOffset CheckedOutAt) { } + public System.DateTimeOffset CheckedOutAt { get; init; } + public string ClerkId { get; init; } + public string GuestStayAccountId { get; init; } + } + public class GuestCheckoutFailed : HotelManagement.GuestStayAccounts.GuestStayAccountEvent, System.IEquatable + { + public GuestCheckoutFailed(string GuestStayAccountId, string ClerkId, HotelManagement.GuestStayAccounts.GuestStayAccountEvent.GuestCheckoutFailed.FailureReason Reason, System.DateTimeOffset FailedAt) { } + public string ClerkId { get; init; } + public System.DateTimeOffset FailedAt { get; init; } + public string GuestStayAccountId { get; init; } + public HotelManagement.GuestStayAccounts.GuestStayAccountEvent.GuestCheckoutFailed.FailureReason Reason { get; init; } + public enum FailureReason + { + NotOpened = 0, + BalanceNotSettled = 1, + } + } + public class PaymentRecorded : HotelManagement.GuestStayAccounts.GuestStayAccountEvent, System.IEquatable + { + public PaymentRecorded(string GuestStayAccountId, decimal Amount, System.DateTimeOffset RecordedAt) { } + public decimal Amount { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset RecordedAt { get; init; } + } + } + public enum GuestStayAccountStatus + { + Opened = 1, + CheckedOut = 2, + } +} \ No newline at end of file diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.verified.txt b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.verified.txt new file mode 100644 index 00000000..eb08f133 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.verified.txt @@ -0,0 +1,46 @@ +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] +namespace ECommerce.V1 +{ + public class PricedProductItem : System.IEquatable + { + public PricedProductItem(ECommerce.V1.ProductItem ProductItem, decimal UnitPrice) { } + public ECommerce.V1.ProductItem ProductItem { get; init; } + public decimal UnitPrice { get; init; } + } + public class ProductItem : System.IEquatable + { + public ProductItem(System.Guid ProductId, int Quantity) { } + public System.Guid ProductId { get; init; } + public int Quantity { get; init; } + } + public class ProductItemAddedToShoppingCart : System.IEquatable + { + public ProductItemAddedToShoppingCart(System.Guid ShoppingCartId, ECommerce.V1.PricedProductItem ProductItem) { } + public ECommerce.V1.PricedProductItem ProductItem { get; init; } + public System.Guid ShoppingCartId { get; init; } + } + public class ProductItemRemovedFromShoppingCart : System.IEquatable + { + public ProductItemRemovedFromShoppingCart(System.Guid ShoppingCartId, ECommerce.V1.PricedProductItem ProductItem) { } + public ECommerce.V1.PricedProductItem ProductItem { get; init; } + public System.Guid ShoppingCartId { get; init; } + } + public class ShoppingCartConfirmed : System.IEquatable + { + public ShoppingCartConfirmed(System.Guid ShoppingCartId, System.DateTime ConfirmedAt) { } + public System.DateTime ConfirmedAt { get; init; } + public System.Guid ShoppingCartId { get; init; } + } + public class ShoppingCartOpened : System.IEquatable + { + public ShoppingCartOpened(System.Guid ShoppingCartId, System.Guid ClientId) { } + public System.Guid ClientId { get; init; } + public System.Guid ShoppingCartId { get; init; } + } + public enum ShoppingCartStatus + { + Pending = 1, + Confirmed = 2, + Cancelled = 3, + } +} \ No newline at end of file diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/MultipleTransformationsWithDifferentEventTypes.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/MultipleTransformationsWithDifferentEventTypes.cs new file mode 100644 index 00000000..19973231 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/MultipleTransformationsWithDifferentEventTypes.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using FluentAssertions; +using HotelManagement.EventStore; +using V1 = HotelManagement.GuestStayAccounts.GuestStayAccountEvent; + +public record Money( + decimal Amount, + string Currency +); + +namespace V2 +{ + public record PaymentRecorded( + string GuestStayAccountId, + Money Amount, + DateTimeOffset RecordedAt + ); +} + +public record PaymentRecorded( + string GuestStayAccountId, + Money Amount, + DateTimeOffset RecordedAt, + string ClerkId +); + +namespace HotelManagement.Tests.Transformations +{ + public class MultipleTransformationsWithDifferentEventTypes + { + public static PaymentRecorded UpcastV1( + JsonDocument oldEventJson + ) + { + var oldEvent = oldEventJson.RootElement; + + return new PaymentRecorded( + oldEvent.GetProperty("GuestStayAccountId").GetString()!, + new Money(oldEvent.GetProperty("Amount").GetDecimal(), "CHF"), + oldEvent.GetProperty("RecordedAt").GetDateTimeOffset(), + "" + ); + } + + public static PaymentRecorded UpcastV2( + V2.PaymentRecorded oldEvent + ) => + new( + oldEvent.GuestStayAccountId, + oldEvent.Amount, + oldEvent.RecordedAt, + "" + ); + + [Fact] + public void UpcastObjects_Should_BeForwardCompatible() + { + // Given + const string eventTypeV1Name = "payment_recorded_v1"; + const string eventTypeV2Name = "payment_recorded_v2"; + const string eventTypeV3Name = "payment_recorded_v3"; + + var mapping = new EventTypeMapping() + .CustomMap( + eventTypeV1Name, + eventTypeV2Name, + eventTypeV3Name + ); + + var transformations = new EventTransformations() + .Register(eventTypeV1Name, UpcastV1) + .Register(eventTypeV2Name, UpcastV2); + + var serializer = new EventSerializer(mapping, transformations); + + var eventV1 = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var eventV2 = new V2.PaymentRecorded( + Guid.NewGuid().ToString(), + new Money((decimal)Random.Shared.NextDouble(), "USD"), + DateTimeOffset.Now + ); + var eventV3 = new PaymentRecorded( + Guid.NewGuid().ToString(), + new Money((decimal)Random.Shared.NextDouble(), "EUR"), + DateTimeOffset.Now, + Guid.NewGuid().ToString() + ); + + var events = new[] + { + new { EventType = eventTypeV1Name, EventData = JsonSerializer.Serialize(eventV1) }, + new { EventType = eventTypeV2Name, EventData = JsonSerializer.Serialize(eventV2) }, + new { EventType = eventTypeV3Name, EventData = JsonSerializer.Serialize(eventV3) } + }; + + // When + var deserializedEvents = events + .Select(ev => serializer.Deserialize(ev.EventType, ev.EventData)) + .OfType() + .ToList(); + + deserializedEvents.Should().HaveCount(3); + + // Then + deserializedEvents[0].GuestStayAccountId.Should().Be(eventV1.GuestStayAccountId); + deserializedEvents[0].Amount.Should().Be(new Money(eventV1.Amount, "CHF")); + deserializedEvents[0].ClerkId.Should().Be(""); + deserializedEvents[0].RecordedAt.Should().Be(eventV1.RecordedAt); + + + deserializedEvents[1].GuestStayAccountId.Should().Be(eventV2.GuestStayAccountId); + deserializedEvents[1].Amount.Should().Be(eventV2.Amount); + deserializedEvents[1].ClerkId.Should().Be(""); + deserializedEvents[1].RecordedAt.Should().Be(eventV2.RecordedAt); + + + deserializedEvents[2].GuestStayAccountId.Should().Be(eventV3.GuestStayAccountId); + deserializedEvents[2].Amount.Should().Be(eventV3.Amount); + deserializedEvents[2].ClerkId.Should().Be(eventV3.ClerkId); + deserializedEvents[2].RecordedAt.Should().Be(eventV3.RecordedAt); + } + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/StreamTransformations.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/StreamTransformations.cs new file mode 100644 index 00000000..8f227055 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/StreamTransformations.cs @@ -0,0 +1,282 @@ +// using System.Text.Json; +// using FluentAssertions; +// using V1 = ECommerce.V1; +// +// namespace HotelManagement.Tests.Transformations; +// +// public class MergeEvents +// { +// public record ShoppingCartInitializedWithProducts( +// Guid ShoppingCartId, +// Guid ClientId, +// List ProductItems +// ); +// +// public record EventMetadata( +// Guid CorrelationId +// ); +// +// public record EventData( +// string EventType, +// string Data, +// string MetaData +// ); +// +// public List FlattenInitializedEventsWithProductItemsAdded( +// List events +// ) +// { +// var cartOpened = events.First(); +// var cartInitializedCorrelationId = +// JsonSerializer.Deserialize(cartOpened.MetaData)! +// .CorrelationId; +// +// var i = 1; +// List productItemsAdded = []; +// +// while (i < events.Count) +// { +// var eventData = events[i]; +// +// if (eventData.EventType != "product_item_added_v1") +// break; +// +// var correlationId = JsonSerializer +// .Deserialize(eventData.MetaData)! +// .CorrelationId; +// +// if (correlationId != cartInitializedCorrelationId) +// break; +// +// productItemsAdded.Add(eventData); +// i++; +// } +// +// var mergedEvent = ToShoppingCartInitializedWithProducts( +// cartOpened, +// productItemsAdded +// ); +// +// return +// [ +// ..new[] { mergedEvent }.Union(events.Skip(i)) +// ]; +// } +// +// private EventData ToShoppingCartInitializedWithProducts( +// EventData shoppingCartInitialized, +// List productItemsAdded +// ) +// { +// var shoppingCartInitializedJson = JsonDocument.Parse(shoppingCartInitialized.Data).RootElement; +// +// var newEvent = new ShoppingCartInitializedWithProducts( +// shoppingCartInitializedJson.GetProperty("ShoppingCartId").GetGuid(), +// shoppingCartInitializedJson.GetProperty("ClientId").GetGuid(), [ +// +// ..productItemsAdded.Select(pi => +// { +// var pricedProductItem = JsonDocument.Parse(pi.Data).RootElement.GetProperty("ProductItem"); +// var productItem = pricedProductItem.GetProperty("ProductItem"); +// +// return new V1.PricedProductItem( +// new V1.ProductItem(productItem.GetProperty("ProductId").GetGuid(), +// productItem.GetProperty("Quantity").GetInt32()), +// pricedProductItem.GetProperty("UnitPrice").GetDecimal()); +// }) +// +// ] +// ); +// +// return new EventData("shopping_cart_initialized_v2", JsonSerializer.Serialize(newEvent), +// shoppingCartInitialized.MetaData); +// } +// +// public class StreamTransformations +// { +// private readonly List, List>> jsonTransformations = []; +// +// public List Transform(List events) +// { +// if (!jsonTransformations.Any()) +// return events; +// +// var result = jsonTransformations +// .Aggregate(events, (current, transform) => transform(current)); +// +// return result; +// } +// +// public StreamTransformations Register( +// Func, List> transformJson +// ) +// { +// jsonTransformations.Add(transformJson); +// return this; +// } +// } +// +// public class EventTransformations +// { +// private readonly Dictionary> jsonTransformations = new(); +// +// public bool TryTransform(string eventTypeName, string json, out object? result) +// { +// if (!jsonTransformations.TryGetValue(eventTypeName, out var transformJson)) +// { +// result = null; +// return false; +// } +// +// result = transformJson(json); +// return true; +// } +// +// public EventTransformations Register(string eventTypeName, Func transformJson) +// where TEvent : notnull +// { +// jsonTransformations.Add( +// eventTypeName, +// json => transformJson(JsonDocument.Parse(json)) +// ); +// return this; +// } +// +// public EventTransformations Register(string eventTypeName, +// Func transformEvent) +// where TOldEvent : notnull +// where TEvent : notnull +// { +// jsonTransformations.Add( +// eventTypeName, +// json => transformEvent(JsonSerializer.Deserialize(json)!) +// ); +// return this; +// } +// } +// +// public class EventTypeMapping +// { +// private readonly Dictionary mappings = new(); +// +// public EventTypeMapping Register(params string[] typeNames) +// { +// var eventType = typeof(TEvent); +// +// foreach (var typeName in typeNames) +// { +// mappings.Add(typeName, eventType); +// } +// +// return this; +// } +// +// public Type Map(string eventType) => mappings[eventType]; +// } +// +// public class EventSerializer( +// EventTypeMapping mapping, +// StreamTransformations streamTransformations, +// EventTransformations transformations) +// { +// public object? Deserialize(string eventTypeName, string json) => +// transformations.TryTransform(eventTypeName, json, out var transformed) +// ? transformed +// : JsonSerializer.Deserialize(json, mapping.Map(eventTypeName)); +// +// public List Deserialize(List events) => +// streamTransformations.Transform(events) +// .Select(@event => Deserialize(@event.EventType, @event.Data)) +// .ToList(); +// } +// +// [Fact] +// public void UpcastObjects_Should_BeForwardCompatible() +// { +// // Given +// var mapping = new EventTypeMapping() +// .Register( +// "shopping_cart_initialized_v2" +// ) +// .Register( +// "product_item_added_v1" +// ) +// .Register( +// "shopping_card_confirmed_v1" +// ); +// +// var streamTransformations = +// new StreamTransformations() +// .Register(FlattenInitializedEventsWithProductItemsAdded); +// +// var serializer = new EventSerializer( +// mapping, +// streamTransformations, +// new EventTransformations() +// ); +// +// var shoppingCardId = Guid.NewGuid(); +// var clientId = Guid.NewGuid(); +// var theSameCorrelationId = Guid.NewGuid(); +// var productItem = new V1.PricedProductItem(new V1.ProductItem(Guid.NewGuid(), 1), 23.22m); +// +// var events = new (string EventTypeName, object EventData, EventMetadata MetaData)[] +// { +// ( +// "shopping_cart_initialized_v1", +// new V1.ShoppingCartOpened(shoppingCardId, clientId), +// new EventMetadata(theSameCorrelationId) +// ), +// ( +// "product_item_added_v1", +// new V1.ProductItemAddedToShoppingCart(shoppingCardId, productItem), +// new EventMetadata(theSameCorrelationId) +// ), +// ( +// "product_item_added_v1", +// new V1.ProductItemAddedToShoppingCart(shoppingCardId, productItem), +// new EventMetadata(theSameCorrelationId) +// ), +// ( +// "product_item_added_v1", +// new V1.ProductItemAddedToShoppingCart(shoppingCardId, productItem), +// new EventMetadata(Guid.NewGuid()) +// ), +// ( +// "shopping_card_confirmed_v1", +// new V1.ShoppingCartConfirmed(shoppingCardId, DateTime.UtcNow), +// new EventMetadata(Guid.NewGuid()) +// ) +// }; +// +// var serialisedEvents = events.Select(e => +// new EventData( +// e.EventTypeName, +// JsonSerializer.Serialize(e.EventData), +// JsonSerializer.Serialize(e.MetaData) +// ) +// ).ToList(); +// +// // When +// var deserializedEvents = serializer.Deserialize(serialisedEvents); +// +// // Then +// deserializedEvents.Should().HaveCount(3); +// deserializedEvents[0].As() +// .ClientId.Should().Be(clientId); +// deserializedEvents[0].As() +// .ShoppingCartId.Should().Be(shoppingCardId); +// deserializedEvents[0].As() +// .ProductItems.Should().HaveCount(2); +// deserializedEvents[0].As() +// .ProductItems.Should().OnlyContain(pr => pr.Equals(productItem)); +// +// deserializedEvents[1].As() +// .ShoppingCartId.Should().Be(shoppingCardId); +// deserializedEvents[1].As() +// .ProductItem.Should().Be(productItem); +// +// deserializedEvents[2].As() +// .ShoppingCartId.Should().Be(shoppingCardId); +// } +// } diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/ChangedStructure.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/ChangedStructure.cs new file mode 100644 index 00000000..de13e2d8 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/ChangedStructure.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts.GuestStayAccountEvent; + +namespace HotelManagement.Tests.Upcasters; + +public class ChangedStructure +{ + public record Money( + decimal Amount, + string Currency = "CHF" + ); + + public record PaymentRecorded( + string GuestStayAccountId, + Money Amount, + DateTimeOffset RecordedAt + ); + + public static PaymentRecorded Upcast( + V1.PaymentRecorded newEvent + ) + { + return new PaymentRecorded( + newEvent.GuestStayAccountId, + new Money(newEvent.Amount), + newEvent.RecordedAt + ); + } + + public static PaymentRecorded Upcast( + string oldEventJson + ) + { + var oldEvent = JsonDocument.Parse(oldEventJson).RootElement; + + return new PaymentRecorded( + oldEvent.GetProperty("GuestStayAccountId").GetString()!, + new Money(oldEvent.GetProperty("Amount").GetDecimal()), + oldEvent.GetProperty("RecordedAt").GetDateTimeOffset() + ); + } + + [Fact] + public void UpcastObjects_Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + + // When + var @event = Upcast(oldEvent); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(new Money(oldEvent.Amount, "CHF")); + } + + [Fact] + public void UpcastJson_Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + + // When + var @event = Upcast( + JsonSerializer.Serialize(oldEvent) + ); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(new Money(oldEvent.Amount, "CHF")); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/NewRequiredPropertyFromMetadata.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/NewRequiredPropertyFromMetadata.cs new file mode 100644 index 00000000..6fb1313f --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/NewRequiredPropertyFromMetadata.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts.GuestStayAccountEvent; + +namespace HotelManagement.Tests.Upcasters; + +public class NewRequiredPropertyFromMetadata +{ + public record EventMetadata( + string UserId + ); + + public record PaymentRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset Now, + string ClerkId + ); + + public static PaymentRecorded Upcast( + V1.PaymentRecorded newEvent, + EventMetadata eventMetadata + ) + { + return new PaymentRecorded( + newEvent.GuestStayAccountId, + newEvent.Amount, + newEvent.RecordedAt, + eventMetadata.UserId + ); + } + + public static PaymentRecorded Upcast( + string oldEventJson, + string eventMetadataJson + ) + { + var oldEvent = JsonDocument.Parse(oldEventJson).RootElement; + var eventMetadata = JsonDocument.Parse(eventMetadataJson).RootElement; + + return new PaymentRecorded( + oldEvent.GetProperty("GuestStayAccountId").GetString()!, + oldEvent.GetProperty("Amount").GetDecimal(), + oldEvent.GetProperty("RecordedAt").GetDateTimeOffset(), + eventMetadata.GetProperty("UserId").GetString()! + ); + } + + [Fact] + public void UpcastObjects_Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var eventMetadata = new EventMetadata(Guid.NewGuid().ToString()); + + // When + var @event = Upcast(oldEvent, eventMetadata); + + @event.Should().NotBeNull(); + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.ClerkId.Should().Be(eventMetadata.UserId); + } + + [Fact] + public void UpcastJson_Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var eventMetadata = new EventMetadata(Guid.NewGuid().ToString()); + + // When + var @event = Upcast( + JsonSerializer.Serialize(oldEvent), + JsonSerializer.Serialize(eventMetadata) + ); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.ClerkId.Should().Be(eventMetadata.UserId); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventSerializer.cs b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventSerializer.cs index e1e28049..785804b1 100644 --- a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventSerializer.cs +++ b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventSerializer.cs @@ -2,10 +2,20 @@ namespace HotelManagement.EventStore; -public class EventSerializer(EventTypeMapping mapping, EventTransformations transformations) +public class EventSerializer( + EventTypeMapping mapping, + EventTransformations transformations, + StreamTransformations? streamTransformations = null) { + private readonly StreamTransformations streamTransformations = streamTransformations ?? new StreamTransformations(); + public object? Deserialize(string eventTypeName, string json) => transformations.TryTransform(eventTypeName, json, out var transformed) ? transformed : JsonSerializer.Deserialize(json, mapping.ToType(eventTypeName)!); + + public List Deserialize(List events) => + streamTransformations.Transform(events) + .Select(@event => Deserialize(@event.EventType, @event.Data)) + .ToList(); } diff --git a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTypeMapping.cs b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTypeMapping.cs index 3b859b90..22c56e1e 100644 --- a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTypeMapping.cs +++ b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTypeMapping.cs @@ -7,12 +7,17 @@ public class EventTypeMapping private readonly ConcurrentDictionary typeMap = new(); private readonly ConcurrentDictionary typeNameMap = new(); - public void AddCustomMap(string eventTypeName) => AddCustomMap(typeof(T), eventTypeName); + public EventTypeMapping CustomMap(params string[] eventTypeNames) => CustomMap(typeof(T), eventTypeNames); - public void AddCustomMap(Type eventType, string eventTypeName) + public EventTypeMapping CustomMap(Type eventType, params string[] eventTypeNames) { - typeNameMap.AddOrUpdate(eventType, eventTypeName, (_, typeName) => typeName); - typeMap.AddOrUpdate(eventTypeName, eventType, (_, type) => type); + foreach (var eventTypeName in eventTypeNames) + { + typeNameMap.AddOrUpdate(eventType, eventTypeName, (_, typeName) => typeName); + typeMap.AddOrUpdate(eventTypeName, eventType, (_, type) => type); + } + + return this; } public string ToName() => ToName(typeof(TEventType));