diff --git a/NMoneys.sln b/NMoneys.sln index 476dfb2..12bdbd3 100644 --- a/NMoneys.sln +++ b/NMoneys.sln @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "archive", "archive", "{DB66 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NMoneys.Exchange", "archive\NMoneys.Exchange\NMoneys.Exchange.csproj", "{97C58515-92D8-4ECF-86B6-CFAABD66279D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NMoneys.Serialization", "src\NMoneys.Serialization\NMoneys.Serialization.csproj", "{F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NMoneys.Serialization.Tests", "tests\NMoneys.Serialization.Tests\NMoneys.Serialization.Tests.csproj", "{A28BEBB8-EC70-4FC5-AFE7-9989F816347B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,10 +40,20 @@ Global {97C58515-92D8-4ECF-86B6-CFAABD66279D}.Debug|Any CPU.Build.0 = Debug|Any CPU {97C58515-92D8-4ECF-86B6-CFAABD66279D}.Release|Any CPU.ActiveCfg = Release|Any CPU {97C58515-92D8-4ECF-86B6-CFAABD66279D}.Release|Any CPU.Build.0 = Release|Any CPU + {F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}.Release|Any CPU.Build.0 = Release|Any CPU + {A28BEBB8-EC70-4FC5-AFE7-9989F816347B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A28BEBB8-EC70-4FC5-AFE7-9989F816347B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A28BEBB8-EC70-4FC5-AFE7-9989F816347B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A28BEBB8-EC70-4FC5-AFE7-9989F816347B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {0484566C-1D24-433E-90F2-B8B44BEF2FB2} = {558BFE88-EB25-4BFA-91B8-4CFF8696CED4} {891B60F6-888D-488A-89D0-6E25FE1A7883} = {6454722E-5710-44B0-8EDE-1DE92929E641} {97C58515-92D8-4ECF-86B6-CFAABD66279D} = {DB66F4E1-075E-4E1C-931E-5DD8F956E484} + {F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF} = {6454722E-5710-44B0-8EDE-1DE92929E641} + {A28BEBB8-EC70-4FC5-AFE7-9989F816347B} = {558BFE88-EB25-4BFA-91B8-4CFF8696CED4} EndGlobalSection EndGlobal diff --git a/src/NMoneys.Serialization/BSON/MoneySerializer.cs b/src/NMoneys.Serialization/BSON/MoneySerializer.cs new file mode 100644 index 0000000..54ee910 --- /dev/null +++ b/src/NMoneys.Serialization/BSON/MoneySerializer.cs @@ -0,0 +1,101 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace NMoneys.Serialization.BSON; + +/// +/// Allows customizing the serialization of monetary quantities +/// +public class MoneySerializer : StructSerializerBase +{ + // maps cannot be changed so the looked-up instance is cached + private readonly Lazy> _map = new(() => + (BsonClassMap)BsonClassMap.LookupClassMap(typeof(Money))); + + /// + public override void Serialize([NotNull]BsonSerializationContext context, BsonSerializationArgs args, Money value) + { + context.Writer.WriteStartDocument(); + + writeAmount(context.Writer, value); + + writeCurrency(context, args, value); + + context.Writer.WriteEndDocument(); + } + + /// + public override Money Deserialize([NotNull]BsonDeserializationContext context, BsonDeserializationArgs args) + { + context.Reader.ReadStartDocument(); + + decimal amount = readAmount(context.Reader); + CurrencyIsoCode currency = readCurrency(context, args); + + context.Reader.ReadEndDocument(); + + var deserialized = new Money(amount, currency); + return deserialized; + } + + #region write + + private void writeAmount(IBsonWriter writer, Money value) + { + string amountName = _map.Value.GetMemberMap(m => m.Amount).ElementName; + writer.WriteName(amountName); + writer.WriteDecimal128(value.Amount); + } + + private void writeCurrency(BsonSerializationContext context, BsonSerializationArgs args, Money value) + { + var currencyMap = _map.Value.GetMemberMap(c => c.CurrencyCode); + string currencyName = char.IsLower(currencyMap.ElementName, 0) ? "currency" : "Currency"; + context.Writer.WriteName(currencyName); + currencyMap.GetSerializer().Serialize(context, args, value.CurrencyCode); + } + + #endregion + + #region read + + private decimal readAmount(IBsonReader reader) + { + var amountMap = _map.Value.GetMemberMap(m => m.Amount); + reader.ReadName(amountMap.ElementName); + BsonType type = reader.GetCurrentBsonType(); + decimal amount; + switch (type) + { + // does not support floating point conversion + case BsonType.String: + string str = reader.ReadString(); + amount = decimal.Parse(str, CultureInfo.InvariantCulture); + break; + case BsonType.Decimal128: + Decimal128 dec = reader.ReadDecimal128(); + amount = Decimal128.ToDecimal(dec); + break; + default: + throw new NotSupportedException($"Cannot extract a monetary amount out of '{type.ToString()}'."); + } + + return amount; + } + + private CurrencyIsoCode readCurrency(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var currencyMap = _map.Value.GetMemberMap(m => m.CurrencyCode); + string currencyName = char.IsLower(currencyMap.ElementName, 0) ? "currency" : "Currency"; + context.Reader.ReadName(currencyName); + var currency = (CurrencyIsoCode)currencyMap.GetSerializer() + .Deserialize(context, args); + return currency; + } + + #endregion +} diff --git a/src/NMoneys.Serialization/BSON/QuantitySerializer.cs b/src/NMoneys.Serialization/BSON/QuantitySerializer.cs new file mode 100644 index 0000000..fe01aad --- /dev/null +++ b/src/NMoneys.Serialization/BSON/QuantitySerializer.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace NMoneys.Serialization.BSON; + +public class QuantitySerializer : StructSerializerBase +{ + /// + public override void Serialize([NotNull]BsonSerializationContext context, BsonSerializationArgs args, Money value) + { + context.Writer.WriteString(value.AsQuantity()); + } + + /// + public override Money Deserialize([NotNull]BsonDeserializationContext context, BsonDeserializationArgs args) + { + string quantity = context.Reader.ReadString(); + + Money deserialized = Money.Parse(quantity); + return deserialized; + } +} diff --git a/src/NMoneys.Serialization/EFCore/JsonConverter.cs b/src/NMoneys.Serialization/EFCore/JsonConverter.cs new file mode 100644 index 0000000..0da02d3 --- /dev/null +++ b/src/NMoneys.Serialization/EFCore/JsonConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; + +namespace NMoneys.Serialization.EFCore; + +/// +/// Defines conversions from an instance of in a model to a in the store. +/// +public class JsonConverter : ValueConverter +{ + /// + /// Initializes a new instance of the class. + /// + public JsonConverter(JsonSerializerOptions options) : base( + m => JsonSerializer.Serialize(m, options), + str => JsonSerializer.Deserialize(str, options)) { } +} diff --git a/src/NMoneys.Serialization/EFCore/MoneyComparer.cs b/src/NMoneys.Serialization/EFCore/MoneyComparer.cs new file mode 100644 index 0000000..9a179ed --- /dev/null +++ b/src/NMoneys.Serialization/EFCore/MoneyComparer.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace NMoneys.Serialization.EFCore; + +/// +/// Specifies custom value snapshotting and comparison for . +/// +public class MoneyComparer : ValueComparer +{ + /// + /// Creates a new . A shallow copy will be used for the snapshot. + /// + public MoneyComparer() : base((x, y) => x.Equals(y), m => m.GetHashCode()) { } +} diff --git a/src/NMoneys.Serialization/EFCore/QuantityConverter.cs b/src/NMoneys.Serialization/EFCore/QuantityConverter.cs new file mode 100644 index 0000000..72c6d80 --- /dev/null +++ b/src/NMoneys.Serialization/EFCore/QuantityConverter.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace NMoneys.Serialization.EFCore; + +/// +/// Defines conversions from an instance of in a model to a in the store. +/// +public class QuantityConverter : ValueConverter +{ + /// + /// Initializes a new instance of the class. + /// + public QuantityConverter() : base( + m => m.AsQuantity(), + str => Money.Parse(str)) { } +} diff --git a/src/NMoneys.Serialization/Json_NET/DescriptiveMoneyConverter.cs b/src/NMoneys.Serialization/Json_NET/DescriptiveMoneyConverter.cs new file mode 100644 index 0000000..230226e --- /dev/null +++ b/src/NMoneys.Serialization/Json_NET/DescriptiveMoneyConverter.cs @@ -0,0 +1,190 @@ +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace NMoneys.Serialization.Json_NET; + +/// +/// Converts an monetary quantity to and from JSON. +/// +/// +/// The serialized quantity would look something like: {"Amount": 0, "Currency": "XXX"}. +/// Provides better JSON pointers for deserialization errors: better suited when not in control of serialization. +/// +public class DescriptiveMoneyConverter : JsonConverter +{ + private readonly bool _forceStringEnum; + + /// Ignore enum value configuration and force string representation of the + /// when serializing. + public DescriptiveMoneyConverter(bool forceStringEnum = false) + { + _forceStringEnum = forceStringEnum; + } + + #region write + + /// + public override void WriteJson([NotNull] JsonWriter writer, Money value, [NotNull] JsonSerializer serializer) + { + DefaultContractResolver? resolver = serializer.ContractResolver as DefaultContractResolver; + + writer.WriteStartObject(); + + writeAmount(value.Amount, writer, resolver); + writeCurrency(value.CurrencyCode, writer, serializer, resolver); + + writer.WriteEndObject(); + } + + private static void writeAmount(decimal amount, JsonWriter writer, DefaultContractResolver? resolver) + { + // non-pascal if "weird" resolver + string amountName = resolver?.GetResolvedPropertyName("Amount") ?? "amount"; + writer.WritePropertyName(amountName); + writer.WriteValue(amount); + } + + private void writeCurrency(CurrencyIsoCode currency, + JsonWriter writer, JsonSerializer serializer, DefaultContractResolver? resolver) + { + // non-pascal if "weird" resolver + string currencyName = resolver?.GetResolvedPropertyName("Currency") ?? "currency"; + writer.WritePropertyName(currencyName); + if (_forceStringEnum) + { + // ignore configured enum value convention, string it is + writer.WriteValue(currency.ToString()); + } + else + { + // follow configured enum value convention + serializer.Serialize(writer, currency, typeof(CurrencyIsoCode)); + } + } + + #endregion + + #region read + + /// + public override Money ReadJson([NotNull] JsonReader reader, + Type objectType, Money existingValue, + bool hasExistingValue, [NotNull] JsonSerializer serializer) + { + DefaultContractResolver? resolver = serializer.ContractResolver as DefaultContractResolver; + + // no need to read StartObject token (already read) + + decimal amount = readAmount(reader, resolver); + CurrencyIsoCode currency = readCurrency(reader, serializer, resolver); + + // but EndObject needs to be read + readEndObject(reader); + + return new Money(amount, currency); + } + + private static decimal readAmount(JsonReader reader, DefaultContractResolver? resolver) + { + readProperty(reader); + ensurePropertyName("Amount", reader, resolver); + decimal amount = readAmountValue(reader); + return amount; + } + + private static CurrencyIsoCode readCurrency(JsonReader reader, JsonSerializer serializer, DefaultContractResolver? resolver) + { + readProperty(reader); + ensurePropertyName("Currency", reader, resolver); + CurrencyIsoCode currency = readCurrencyValue(reader, serializer); + return currency; + } + + private static void readEndObject(JsonReader reader) + { + bool read = reader.Read(); + if (!read || reader.TokenType != JsonToken.EndObject) + { + throw buildException($"Expected token type '{JsonToken.EndObject}', but got '{reader.TokenType}'.", reader); + } + } + + private static JsonSerializationException buildException(string message, JsonReader reader, Exception? inner = null) + { + IJsonLineInfo? info = reader as IJsonLineInfo; + JsonSerializationException exception = (info == null || info.HasLineInfo()) ? + new JsonSerializationException(message) : + new JsonSerializationException(message, reader.Path, info.LineNumber, info.LinePosition, inner); + return exception; + } + + + private static void readProperty(JsonReader reader) + { + bool isRead = reader.Read(); + if (!isRead || reader.TokenType != JsonToken.PropertyName) + { + throw buildException($"Expected token type '{JsonToken.PropertyName}', but got '{reader.TokenType}'.", reader); + } + } + + private static void ensurePropertyName(string pascalSingleName, JsonReader reader, DefaultContractResolver? resolver) + { +#pragma warning disable CA1308 + string propName = resolver?.GetResolvedPropertyName(pascalSingleName) ?? pascalSingleName.ToLowerInvariant(); +#pragma warning restore CA1308 + bool matchAmount = StringComparer.Ordinal.Equals(reader.Value, propName); + if (!matchAmount) + { + throw buildException($"Expected property '{propName}', but got '{reader.Value}'.", reader); + } + } + + private static decimal readAmountValue(JsonReader reader) + { + try + { + var amount = reader.ReadAsDecimal(); + if (!amount.HasValue) + { + throw buildException("Amount should not be nullable.", reader); + } + + return amount.Value; + } + catch (Exception ex) + { + throw buildException("Could not read amount value.", reader, ex); + } + } + + private static CurrencyIsoCode readCurrencyValue(JsonReader reader, JsonSerializer serializer) + { + bool read = reader.Read(); + if (!read) + { + throw buildException("Expected value token type.", reader); + } + + CurrencyIsoCode currency = serializer.Deserialize(reader); + ensureDefined(currency, reader); + + return currency; + } + + private static void ensureDefined(CurrencyIsoCode maybeCurrency, JsonReader reader) + { + try + { + maybeCurrency.AssertDefined(); + } + catch (Exception ex) + { + throw buildException($"Currency '{maybeCurrency}' not defined.", reader, ex); + } + } + + + #endregion +} diff --git a/src/NMoneys.Serialization/Json_NET/MoneyConverter.cs b/src/NMoneys.Serialization/Json_NET/MoneyConverter.cs new file mode 100644 index 0000000..3c17645 --- /dev/null +++ b/src/NMoneys.Serialization/Json_NET/MoneyConverter.cs @@ -0,0 +1,119 @@ +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace NMoneys.Serialization.Json_NET; + +/// +/// Converts an monetary quantity to and from JSON. +/// +/// +/// The serialized quantity would look something like: {"Amount": 0, "Currency": "XXX"}. +/// Provides limited JSON pointers for deserialization errors: better suited when in control of serialization. +/// +public class MoneyConverter : JsonConverter +{ + private readonly bool _forceStringEnum; + + /// Ignore enum value configuration and force string representation of the + /// when serializing. + public MoneyConverter(bool forceStringEnum = false) + { + _forceStringEnum = forceStringEnum; + } + + #region read + + /// + public override Money ReadJson([NotNull] JsonReader reader, + Type objectType, Money existingValue, + bool hasExistingValue, [NotNull] JsonSerializer serializer) + { + JObject obj = readObject(reader); + decimal amount = getAmount(obj); + CurrencyIsoCode currency = getCurrency(obj); + return new Money(amount, currency); + } + + private static JObject readObject(JsonReader reader) + { + JToken objToken = JToken.ReadFrom(reader); + if (objToken.Type != JTokenType.Object) + { + throw new JsonSerializationException($"Expected token '{JTokenType.Object}', but got '{objToken.Type}'."); + } + + return (JObject)objToken; + } + + private static decimal getAmount(JObject obj) + { + string propName = "Amount"; + JProperty amountProp = getProperty(obj, propName); + decimal? amount = amountProp.Value.Value(); + return amount ?? throw new JsonSerializationException($"'{propName}' cannot be null."); + } + + private static CurrencyIsoCode getCurrency(JObject obj) + { + string propName = "Currency"; + JProperty currencyProp = getProperty(obj, propName); + var currency = currencyProp.Value.ToObject(); + currency.AssertDefined(); + return currency; + } + + private static JProperty getProperty(JObject obj, string singleWordPropName) + { + // since props are single-word, case ignoring cover most common: pascal, camel, snake, kebab + JProperty? amountProp = obj.Property(singleWordPropName, StringComparison.OrdinalIgnoreCase) ?? + throw new JsonSerializationException($"Missing property '{singleWordPropName}'."); + return amountProp; + } + + #endregion + + #region write + + /// + public override void WriteJson([NotNull] JsonWriter writer, Money value, [NotNull] JsonSerializer serializer) + { + DefaultContractResolver? resolver = serializer.ContractResolver as DefaultContractResolver; + + writer.WriteStartObject(); + + writeAmount(value.Amount, writer, resolver); + writeCurrency(value.CurrencyCode, writer, serializer, resolver); + + writer.WriteEndObject(); + } + + private static void writeAmount(decimal amount, JsonWriter writer, DefaultContractResolver? resolver) + { + // non-pascal if "weird" resolver + string amountName = resolver?.GetResolvedPropertyName("Amount") ?? "Amount"; + writer.WritePropertyName(amountName); + writer.WriteValue(amount); + } + + private void writeCurrency(CurrencyIsoCode currency, + JsonWriter writer, JsonSerializer serializer, DefaultContractResolver? resolver) + { + // non-pascal if "weird" resolver + string currencyName = resolver?.GetResolvedPropertyName("Currency") ?? "Currency"; + writer.WritePropertyName(currencyName); + if (_forceStringEnum) + { + // ignore configured enum value convention, string it is + writer.WriteValue(currency.ToString()); + } + else + { + // follow configured enum value convention + serializer.Serialize(writer, currency, typeof(CurrencyIsoCode)); + } + } + + #endregion +} diff --git a/src/NMoneys.Serialization/Json_NET/QuantityConverter.cs b/src/NMoneys.Serialization/Json_NET/QuantityConverter.cs new file mode 100644 index 0000000..a4d10cf --- /dev/null +++ b/src/NMoneys.Serialization/Json_NET/QuantityConverter.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NMoneys.Serialization.Json_NET; + +/// +/// Converts an monetary quantity to and from JSON. +/// +/// +/// The serialized quantity would look something like: "XTS 0". +/// +public class QuantityConverter : JsonConverter +{ + /// + public override Money ReadJson([NotNull] JsonReader reader, + Type objectType, Money existingValue, + bool hasExistingValue, [NotNull] JsonSerializer serializer) + { + JToken strToken = JToken.ReadFrom(reader); + if (strToken.Type != JTokenType.String) + { + throw new JsonSerializationException($"Expected token '{JTokenType.String}', but got '{strToken.Type}'."); + } + + string quantity = strToken.Value() ?? throw new JsonSerializationException($"Quantity cannot be null."); + + Money parsed = Money.Parse(quantity); + return parsed; + } + + /// + public override void WriteJson([NotNull] JsonWriter writer, Money value, [NotNull] JsonSerializer serializer) + { + writer.WriteValue(value.AsQuantity()); + } +} diff --git a/src/NMoneys.Serialization/NMoneys.Serialization.csproj b/src/NMoneys.Serialization/NMoneys.Serialization.csproj new file mode 100644 index 0000000..e5ada77 --- /dev/null +++ b/src/NMoneys.Serialization/NMoneys.Serialization.csproj @@ -0,0 +1,54 @@ + + + + net6.0 + enable + enable + + true + true + + + + Custom serialization/deserialization code samples of monetary quantities with several popular serialization libraries. + NMoneys.Serialization + Copyright © Daniel Gonzalez Garcia 2024 + + + + https://github.com/dgg/nmoneys.git + git + .net;dotnet;C#;currency;money;iso;monetary;quantity;iso4217;serialization;Json.NET;BSON;Xml;System.Text.Json;Entity Framework + + + + 5.0.0 + 5.0.0.0 + 5.0.0.0 + + + + + <_Parameter1>false + + + + + + + + + + + CA1707,CA1508 + + + + + + + + + + + diff --git a/src/NMoneys.Serialization/Text_Json/MoneyConverter.cs b/src/NMoneys.Serialization/Text_Json/MoneyConverter.cs new file mode 100644 index 0000000..80cc2b7 --- /dev/null +++ b/src/NMoneys.Serialization/Text_Json/MoneyConverter.cs @@ -0,0 +1,98 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace NMoneys.Serialization.Text_Json; + +/// +/// Converts an monetary quantity to and from JSON. +/// +/// +/// The serialized quantity would look something like: {"Amount": 0, "Currency": "XXX"}. +/// Provides limited JSON pointers for deserialization errors: better suited when in control of serialization. +/// +public class MoneyConverter : JsonConverter +{ + // forcing enum string argument is not a good idea as deserialization will fail + + #region read + + public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + JsonObject obj = parseObject(ref reader); + + decimal amount = getAmount(obj); + CurrencyIsoCode currency = getCurrency(obj, options); + + return new Money(amount, currency); + } + + private static JsonObject parseObject(ref Utf8JsonReader reader) + { + var nodeOpts = new JsonNodeOptions { PropertyNameCaseInsensitive = true }; + + JsonNode? objNode = JsonNode.Parse(ref reader, nodeOpts) ?? throw new JsonException($"Expected '{nameof(JsonObject)}' but was 'null'."); + JsonObject obj = objNode.AsObject(); + return obj; + } + + private static decimal getAmount(JsonObject obj) + { + string propName = "Amount"; + JsonNode amountNode = getProperty(obj, propName); + var amount = amountNode.GetValue(); + return amount; + } + + private static CurrencyIsoCode getCurrency(JsonObject obj, JsonSerializerOptions options) + { + string propName = "Currency"; + JsonNode currencyNode = getProperty(obj, propName); + var currency = currencyNode.Deserialize(options); + currency.AssertDefined(); + return currency; + } + + private static JsonNode getProperty(JsonObject obj, string singleWordPropName) + { + if (!obj.TryGetPropertyValue(singleWordPropName, out JsonNode? propertyNode) || propertyNode == null) + { + throw new JsonException($"Missing property '{singleWordPropName}'."); + } + + return propertyNode; + } + + #endregion + + #region write + + public override void Write([NotNull] Utf8JsonWriter writer, Money value, [NotNull] JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writeAmount(value.Amount, writer, options); + writeCurrency(value.CurrencyCode, writer, options); + + writer.WriteEndObject(); + } + + private static void writeAmount(decimal amount, Utf8JsonWriter writer, JsonSerializerOptions options) + { + // pascal if no policy override + string amountName = options.PropertyNamingPolicy?.ConvertName("Amount") ?? "Amount"; + writer.WriteNumber(amountName, amount); + } + + private static void writeCurrency(CurrencyIsoCode currency, Utf8JsonWriter writer, JsonSerializerOptions options) + { + // pascal if no policy override + string currencyName = options.PropertyNamingPolicy?.ConvertName("Currency") ?? "Currency"; + + writer.WritePropertyName(currencyName); + JsonSerializer.Serialize(writer, currency, options); + } + + #endregion +} diff --git a/src/NMoneys.Serialization/Text_Json/QuantityConverter.cs b/src/NMoneys.Serialization/Text_Json/QuantityConverter.cs new file mode 100644 index 0000000..2e24548 --- /dev/null +++ b/src/NMoneys.Serialization/Text_Json/QuantityConverter.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace NMoneys.Serialization.Text_Json; + +/// +/// Converts an monetary quantity to and from JSON. +/// +/// +/// The serialized quantity would look something like: "XXX 0". +/// +public class QuantityConverter : JsonConverter +{ + public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + JsonNode strNode = JsonNode.Parse(ref reader) ?? throw new JsonException($"Expected '{nameof(JsonValue)}' but was 'null'.");; + JsonValue value = strNode.AsValue(); + string quantity = value.GetValue(); + Money parsed = Money.Parse(quantity); + return parsed; + } + + public override void Write([NotNull] Utf8JsonWriter writer, Money value, [NotNull] JsonSerializerOptions options) + { + writer.WriteStringValue(value.AsQuantity()); + } +} diff --git a/src/NMoneys/CurrencyIsoCode.Extensions.cs b/src/NMoneys/CurrencyIsoCode.Extensions.cs index bf910a3..fd8a957 100644 --- a/src/NMoneys/CurrencyIsoCode.Extensions.cs +++ b/src/NMoneys/CurrencyIsoCode.Extensions.cs @@ -76,7 +76,7 @@ public static bool Equals(this CurrencyIsoCode code, CurrencyIsoCode other) /// Asserts whether the code exists in the enumeration, throwing if it doesn't. /// /// The enumeration value to assert against. - /// is not defined within . + /// is not defined within . public static void AssertDefined(this CurrencyIsoCode code) { if (!CheckDefined(code)) diff --git a/src/NMoneys/MonetaryQuantity.cs b/src/NMoneys/MonetaryQuantity.cs new file mode 100644 index 0000000..6ab334d --- /dev/null +++ b/src/NMoneys/MonetaryQuantity.cs @@ -0,0 +1,87 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NMoneys; + +/// +/// Represents the data of a monetary quantity. +/// +/// Useful when serialization platform cannot be configured or it is very complex. +/// In that scenario, one can use this class in the Data-Transfer Objects. +/// +public class MonetaryQuantity +{ + /// + /// Initializes an instance of with an amount of 0 and no currency (). + /// + public MonetaryQuantity() + { + Currency = CurrencyIsoCode.XXX; + } + + /// + /// Initializes an instance of with the provided amount and currency code (). + /// + /// The amount of a monetary quantity data. + /// The ISO 4217 code of the currency of a monetary quantity data. + public MonetaryQuantity(decimal amount, CurrencyIsoCode currency) + { + Amount = amount; + Currency = currency; + } + + /// + /// The amount of a monetary quantity data. + /// + public decimal Amount { get; set; } + /// + /// The ISO 4217 code of the currency of a monetary quantity data. + /// + public CurrencyIsoCode Currency { get; set; } + + /// + /// Defines an explicit conversion of a monetary quantity data () to a monetary quantity (). + /// + /// The monetary quantity data to convert. + /// The converted monetary quantity. + public static explicit operator Money([NotNull]MonetaryQuantity quantity) => new(quantity.Amount, quantity.Currency); + /// + /// Defines a conversion of a monetary quantity data () to a monetary quantity (). + /// + /// The monetary quantity data to convert. + /// The converted monetary quantity. + public static Money ToMoney(MonetaryQuantity quantity) + { + return (Money)quantity; + } + /// + /// Defines a conversion of the current monetary quantity data () to a monetary quantity (). + /// + /// The converted monetary quantity. + public Money ToMoney() + { + return (Money)this; + } + + /// + /// Defines an explicit conversion of a monetary quantity () to a monetary quantity data (). + /// + /// The monetary quantity to convert. + /// The converted monetary quantity data. + public static explicit operator MonetaryQuantity(Money money) => new(money.Amount, money.CurrencyCode); + /// + /// Defines a conversion of a monetary quantity () to a monetary quantity data (). + /// + /// The monetary quantity to convert. + /// The converted monetary quantity data. + public static MonetaryQuantity FromMoney(Money money) + { + return (MonetaryQuantity)money; + } + + /// + /// A string with the format '{three_letter_currency_code} {invariant_numeric_amount}' is used due to ease of parsing and compactness. + public override string ToString() + { + return Money.AsQuantity(Amount, Currency); + } +} diff --git a/src/NMoneys/MonetaryRecord.cs b/src/NMoneys/MonetaryRecord.cs new file mode 100644 index 0000000..42178cd --- /dev/null +++ b/src/NMoneys/MonetaryRecord.cs @@ -0,0 +1,61 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NMoneys; + +/// +/// Represents the data of a monetary quantity. +/// +/// Useful when serialization platform cannot be configured or it is very complex. +/// In that scenario, one can use this record in the Data-Transfer Objects. +/// +/// The amount of a monetary quantity data. +/// The ISO 4217 code of the currency of a monetary quantity data. +public record MonetaryRecord(decimal Amount = Decimal.Zero, CurrencyIsoCode Currency = CurrencyIsoCode.XXX) +{ + /// + /// Defines an explicit conversion of a monetary quantity data () to a monetary quantity (). + /// + /// The monetary quantity data to convert. + /// The converted monetary quantity. + public static explicit operator Money([NotNull]MonetaryRecord quantity) => new(quantity.Amount, quantity.Currency); + /// + /// Defines a conversion of a monetary quantity data () to a monetary quantity (). + /// + /// The monetary quantity data to convert. + /// The converted monetary quantity. + public static Money ToMoney(MonetaryRecord quantity) + { + return (Money)quantity; + } + /// + /// Defines a conversion of the current monetary quantity data () to a monetary quantity (). + /// + /// The converted monetary quantity. + public Money ToMoney() + { + return (Money)this; + } + + /// + /// Defines an explicit conversion of a monetary quantity () to a monetary quantity data (). + /// + /// The monetary quantity to convert. + /// The converted monetary quantity data. + public static explicit operator MonetaryRecord(Money money) => new(money.Amount, money.CurrencyCode); + /// + /// Defines a conversion of a monetary quantity () to a monetary quantity data (). + /// + /// The monetary quantity to convert. + /// The converted monetary quantity data. + public static MonetaryRecord FromMoney(Money money) + { + return (MonetaryRecord)money; + } + + /// + /// A string with the format '{three_letter_currency_code} {invariant_numeric_amount}' is used due to ease of parsing and compactness. + public override string ToString() + { + return Money.AsQuantity(Amount, Currency); + } +}; diff --git a/src/NMoneys/Money.Formatting.cs b/src/NMoneys/Money.Formatting.cs index 8dc84dc..cfcdfae 100644 --- a/src/NMoneys/Money.Formatting.cs +++ b/src/NMoneys/Money.Formatting.cs @@ -1,8 +1,9 @@ using System.Diagnostics.Contracts; +using System.Globalization; namespace NMoneys; -public partial struct Money +public partial struct Money { /// /// Converts the numeric value of the to its equivalent string representation using an instance of the @@ -60,91 +61,123 @@ public string ToString(string format, IFormatProvider provider) } /// - /// Replaces the format item in a specified string with information from the - /// identified by the instance's . - /// An instance of the identified by will be supplying culture-specific formatting information. - /// - /// - /// The following table describes the tokens that will be replaced in the : - /// - /// - /// Token - /// Description - /// - /// - /// {0} - /// This token represents the of the current instance. - /// - /// - /// {1} - /// This token represents the for the currency of the current instance - /// - /// - /// {2} - /// This token represents the for the currency of the current instance - /// - /// - /// {3} - /// This token represents the for the currency of the current instance - /// - /// - /// {4} - /// This token represents the for the currency of the current instance - /// - /// - /// - /// A composite format string that can contain tokens to be replaced by properties of the - /// identified by the instance's . - /// A copy of in which the format items have been replaced by the string representation of the corresponding tokens. - [Pure] - public string Format(string format) - { - Currency currency = Currency.Get(CurrencyCode); - return string.Format(currency, format, Amount, currency.Symbol, currency.IsoCode, currency.EnglishName, currency.NativeName); - } + /// Returns a string representation of the monetary quantity suitable for serialization/storage. + /// + /// + /// A string with the format '{three_letter_currency_code} {invariant_numeric_amount}' is used due to ease of parsing and compactness. + /// + /// A compact, unequivocal representation of the monetary quantity. + [Pure] + public string AsQuantity() + { + string quantity = AsQuantity(Amount, CurrencyCode); + return quantity; + } + + /// + /// Returns a string representation of the monetary quantity suitable for serialization/storage. + /// + /// + /// A string with the format '{three_letter_currency_code} {invariant_numeric_amount}' is used due to ease of parsing and compactness. + /// + /// The amount of a monetary quantity. + /// The ISO 4217 code of the currency of a monetary quantity. + /// A compact, unequivocal representation of the monetary quantity. + [Pure] + internal static string AsQuantity(decimal amount, CurrencyIsoCode currency) + { + string quantity = string.Format(NumberFormatInfo.InvariantInfo, "{0} {1:r}", currency, amount); + return quantity; + } /// - /// Replaces the format item in a specified string with information from the - /// identified by the instance's . - /// The will be supplying culture-specific formatting information. - /// - /// - /// The following table describes the tokens that will be replaced in the : - /// - /// - /// Token - /// Description - /// - /// - /// {0} - /// This token represents the of the current instance. - /// - /// - /// {1} - /// This token represents the for the currency of the current instance - /// - /// - /// {2} - /// This token represents the for the currency of the current instance - /// - /// - /// {3} - /// This token represents the for the currency of the current instance - /// - /// - /// {4} - /// This token represents the for the currency of the current instance - /// - /// - /// - /// A composite format string that can contain tokens to be replaced by properties of the - /// identified by the instance's . - /// An object that supplies culture-specific formatting information. - /// A copy of in which the format items have been replaced by the string representation of the corresponding tokens. - [Pure] - public string Format(string format, IFormatProvider provider) - { - Currency currency = Currency.Get(CurrencyCode); - return string.Format(provider, format, Amount, currency.Symbol, currency.IsoCode, currency.EnglishName, currency.NativeName); - } + /// Replaces the format item in a specified string with information from the + /// identified by the instance's . + /// An instance of the identified by will be supplying culture-specific formatting information. + /// + /// + /// The following table describes the tokens that will be replaced in the : + /// + /// + /// Token + /// Description + /// + /// + /// {0} + /// This token represents the of the current instance. + /// + /// + /// {1} + /// This token represents the for the currency of the current instance + /// + /// + /// {2} + /// This token represents the for the currency of the current instance + /// + /// + /// {3} + /// This token represents the for the currency of the current instance + /// + /// + /// {4} + /// This token represents the for the currency of the current instance + /// + /// + /// + /// A composite format string that can contain tokens to be replaced by properties of the + /// identified by the instance's . + /// A copy of in which the format items have been replaced by the string representation of the corresponding tokens. + [Pure] + public string Format(string format) + { + Currency currency = Currency.Get(CurrencyCode); + return string.Format(currency, format, Amount, currency.Symbol, currency.IsoCode, currency.EnglishName, + currency.NativeName); + } + + /// + /// Replaces the format item in a specified string with information from the + /// identified by the instance's . + /// The will be supplying culture-specific formatting information. + /// + /// + /// The following table describes the tokens that will be replaced in the : + /// + /// + /// Token + /// Description + /// + /// + /// {0} + /// This token represents the of the current instance. + /// + /// + /// {1} + /// This token represents the for the currency of the current instance + /// + /// + /// {2} + /// This token represents the for the currency of the current instance + /// + /// + /// {3} + /// This token represents the for the currency of the current instance + /// + /// + /// {4} + /// This token represents the for the currency of the current instance + /// + /// + /// + /// A composite format string that can contain tokens to be replaced by properties of the + /// identified by the instance's . + /// An object that supplies culture-specific formatting information. + /// A copy of in which the format items have been replaced by the string representation of the corresponding tokens. + [Pure] + public string Format(string format, IFormatProvider provider) + { + Currency currency = Currency.Get(CurrencyCode); + return string.Format(provider, format, Amount, currency.Symbol, currency.IsoCode, currency.EnglishName, + currency.NativeName); + } } diff --git a/src/NMoneys/Money.Parsing.cs b/src/NMoneys/Money.Parsing.cs index c0eee35..ee845c4 100644 --- a/src/NMoneys/Money.Parsing.cs +++ b/src/NMoneys/Money.Parsing.cs @@ -5,146 +5,200 @@ namespace NMoneys; public partial struct Money { -/// - /// Converts the string representation of a monetary quantity to its equivalent - /// using the style and the specified currency as format information. - /// - /// This method assumes to have a style. - /// The string representation of the monetary quantity to convert. - /// Expected currency of that provides format information. - /// The equivalent to the monetary quantity contained in as specified by the . - /// is not in the correct format. - /// represents a monetary quantity less than or greater than . - /// is null. - /// - [Pure] - public static Money Parse(string s, Currency currency) - { - return Parse(s, NumberStyles.Currency, currency); - } + /// + /// Converts the string representation of a monetary quantity () to its equivalent. + /// + /// The currency value does not contain enumeration information. + /// The amount value is not in the correct format. + /// The currency value does not contain a defined . + /// Components of the representation are missing. + /// Monetary quantity equivalent to its representation. + [Pure] + public static Money Parse(string quantity) + { + ReadOnlySpan span = quantity.AsSpan(); + CurrencyIsoCode currency = Enum.Parse(span[..3], ignoreCase: false); + currency.AssertDefined(); + decimal amount = decimal.Parse(span[4..], + style: NumberStyles.Float, + provider: NumberFormatInfo.InvariantInfo); + return new Money(amount, currency); + } - /// - /// Converts the string representation of a monetary quantity to its equivalent - /// using the specified style and the specified currency as format information. - /// - /// Use this method when is suspected not to have a style or more control over the operation is needed. - /// The string representation of the monetary quantity to convert. - /// A bitwise combination of values that indicates the style elements that can be present in . - /// A typical value to specify is . - /// Expected currency of that provides format information. - /// The equivalent to the monetary quantity contained in as specified by and . - /// is not in the correct format. - /// represents a monetary quantity less than or greater than . - /// is null. - /// is not a value - /// -or- - /// is the value. - /// - /// - [Pure] - public static Money Parse(string s, NumberStyles style, Currency currency) + /// + /// Tries to converts the string representation of a monetary quantity () to its equivalent. + /// + /// + /// When this method returns, contains the monetary quantity equivalent to its representation contained in , + /// if the conversion succeeded, or null if the conversion failed. + /// true if was converted successfully; otherwise, false. + [Pure] + public static bool TryParse(string quantity, out Money? money) + { + ReadOnlySpan span = quantity.AsSpan(); + try { - decimal amount = decimal.Parse(s, style, currency); - - return new Money(amount, currency); + if (Enum.TryParse(span[..3], out CurrencyIsoCode currencyCode) && + currencyCode.CheckDefined() && + decimal.TryParse(span[4..], NumberStyles.Float, NumberFormatInfo.InvariantInfo, out decimal amount)) + { + money = new Money(amount, currencyCode); + return true; + } } - - /// - /// Converts the string representation of a monetary quantity to its equivalent - /// using and the provided currency as format information. - /// A return value indicates whether the conversion succeeded or failed. - /// - /// The string representation of the monetary quantity to convert. - /// Expected currency of that provides format information. - /// When this method returns, contains the that is equivalent to the monetary quantity contained in , - /// if the conversion succeeded, or is null if the conversion failed. - /// The conversion fails if the parameter is null, is not in a format compliant with currency style, - /// or represents a number less than or greater than . - /// This parameter is passed uninitialized. - /// true if s was converted successfully; otherwise, false. - /// - [Pure] - public static bool TryParse(string s, Currency currency, out Money? money) +#pragma warning disable CA1031 + catch { - return TryParse(s, NumberStyles.Currency, currency, out money); + // span indexing operation can throw } +#pragma warning restore CA1031 - /// - /// Converts the string representation of a monetary quantity to its equivalent - /// using the specified style and the provided currency as format information. - /// A return value indicates whether the conversion succeeded or failed. - /// - /// The string representation of the monetary quantity to convert. - /// A bitwise combination of enumeration values that indicates the permitted format of . - /// A typical value to specify is . - /// Expected currency of that provides format information. - /// When this method returns, contains the that is equivalent to the monetary quantity contained in , - /// if the conversion succeeded, or is null if the conversion failed. - /// The conversion fails if the parameter is null, is not in a format compliant with currency style, - /// or represents a number less than or greater than . - /// This parameter is passed uninitialized. - /// true if s was converted successfully; otherwise, false. - /// is not a value - /// -or- - /// is the value. - /// - /// is null. - /// - [Pure] - public static bool TryParse(string s, NumberStyles style, Currency currency, out Money? money) - { - ArgumentNullException.ThrowIfNull(currency, nameof(currency)); - money = tryParse(s, style, currency.FormatInfo, currency.IsoCode); - return money.HasValue; - } + money = null; + return false; + } - /// - /// Converts the string representation of a monetary quantity to its equivalent - /// using the specified style, the and the symbol of the provided - /// as format information. - /// A return value indicates whether the conversion succeeded or failed. - /// - /// The string representation of the monetary quantity to convert. - /// A bitwise combination of enumeration values that indicates the permitted format of . - /// A typical value to specify is . - /// - /// The format info for the textual representation of the currency. This may be different from - /// how the currency would normally be represented in the country that uses it. For example, the - /// for en-GB would - /// allow the string "€10,000.00" for , even though this would normally be written as - /// "10.000,00 €" - /// - /// Resultant currency of (only symbol is used for formatting). - /// - /// When this method returns, contains the that is equivalent to the monetary quantity - /// contained in , - /// if the conversion succeeded, or is null if the conversion failed. - /// The conversion fails if the parameter is null, is not in a format compliant with currency - /// style, - /// or represents a number less than or greater than - /// . - /// This parameter is passed uninitialized. - /// - /// true if s was converted successfully; otherwise, false. - /// or is null. - [Pure] - public static bool TryParse(string s, NumberStyles style, NumberFormatInfo numberFormatInfo, Currency currency, out Money? money) - { - ArgumentNullException.ThrowIfNull(numberFormatInfo, nameof(numberFormatInfo)); - ArgumentNullException.ThrowIfNull(currency, nameof(currency)); + /// + /// Converts the string representation of a monetary quantity to its equivalent + /// using the style and the specified currency as format information. + /// + /// This method assumes to have a style. + /// The string representation of the monetary quantity to convert. + /// Expected currency of that provides format information. + /// The equivalent to the monetary quantity contained in as specified by the . + /// is not in the correct format. + /// represents a monetary quantity less than or greater than . + /// is null. + /// + [Pure] + public static Money Parse(string s, Currency currency) + { + return Parse(s, NumberStyles.Currency, currency); + } - var mergedNumberFormatInfo = (NumberFormatInfo)numberFormatInfo.Clone(); - mergedNumberFormatInfo.CurrencySymbol = currency.Symbol; + /// + /// Converts the string representation of a monetary quantity to its equivalent + /// using the specified style and the specified currency as format information. + /// + /// Use this method when is suspected not to have a style or more control over the operation is needed. + /// The string representation of the monetary quantity to convert. + /// A bitwise combination of values that indicates the style elements that can be present in . + /// A typical value to specify is . + /// Expected currency of that provides format information. + /// The equivalent to the monetary quantity contained in as specified by and . + /// is not in the correct format. + /// represents a monetary quantity less than or greater than . + /// is null. + /// is not a value + /// -or- + /// is the value. + /// + /// + [Pure] + public static Money Parse(string s, NumberStyles style, Currency currency) + { + decimal amount = decimal.Parse(s, style, currency); - money = tryParse(s, style, mergedNumberFormatInfo, currency.IsoCode); + return new Money(amount, currency); + } - return money.HasValue; - } + /// + /// Converts the string representation of a monetary quantity to its equivalent + /// using and the provided currency as format information. + /// A return value indicates whether the conversion succeeded or failed. + /// + /// The string representation of the monetary quantity to convert. + /// Expected currency of that provides format information. + /// When this method returns, contains the that is equivalent to the monetary quantity contained in , + /// if the conversion succeeded, or is null if the conversion failed. + /// The conversion fails if the parameter is null, is not in a format compliant with currency style, + /// or represents a number less than or greater than . + /// This parameter is passed uninitialized. + /// true if s was converted successfully; otherwise, false. + /// + [Pure] + public static bool TryParse(string s, Currency currency, out Money? money) + { + return TryParse(s, NumberStyles.Currency, currency, out money); + } - private static Money? tryParse(string s, NumberStyles style, NumberFormatInfo formatProvider, CurrencyIsoCode currency) - { - decimal amount; - bool result = decimal.TryParse(s, style, formatProvider, out amount); - return result ? new Money(amount, currency) : null; - } + /// + /// Converts the string representation of a monetary quantity to its equivalent + /// using the specified style and the provided currency as format information. + /// A return value indicates whether the conversion succeeded or failed. + /// + /// The string representation of the monetary quantity to convert. + /// A bitwise combination of enumeration values that indicates the permitted format of . + /// A typical value to specify is . + /// Expected currency of that provides format information. + /// When this method returns, contains the that is equivalent to the monetary quantity contained in , + /// if the conversion succeeded, or is null if the conversion failed. + /// The conversion fails if the parameter is null, is not in a format compliant with currency style, + /// or represents a number less than or greater than . + /// This parameter is passed uninitialized. + /// true if s was converted successfully; otherwise, false. + /// is not a value + /// -or- + /// is the value. + /// + /// is null. + /// + [Pure] + public static bool TryParse(string s, NumberStyles style, Currency currency, out Money? money) + { + ArgumentNullException.ThrowIfNull(currency, nameof(currency)); + money = tryParse(s, style, currency.FormatInfo, currency.IsoCode); + return money.HasValue; + } + + /// + /// Converts the string representation of a monetary quantity to its equivalent + /// using the specified style, the and the symbol of the provided + /// as format information. + /// A return value indicates whether the conversion succeeded or failed. + /// + /// The string representation of the monetary quantity to convert. + /// A bitwise combination of enumeration values that indicates the permitted format of . + /// A typical value to specify is . + /// + /// The format info for the textual representation of the currency. This may be different from + /// how the currency would normally be represented in the country that uses it. For example, the + /// for en-GB would + /// allow the string "€10,000.00" for , even though this would normally be written as + /// "10.000,00 €" + /// + /// Resultant currency of (only symbol is used for formatting). + /// + /// When this method returns, contains the that is equivalent to the monetary quantity + /// contained in , + /// if the conversion succeeded, or is null if the conversion failed. + /// The conversion fails if the parameter is null, is not in a format compliant with currency + /// style, + /// or represents a number less than or greater than + /// . + /// This parameter is passed uninitialized. + /// + /// true if s was converted successfully; otherwise, false. + /// or is null. + [Pure] + public static bool TryParse(string s, NumberStyles style, NumberFormatInfo numberFormatInfo, Currency currency, + out Money? money) + { + ArgumentNullException.ThrowIfNull(numberFormatInfo, nameof(numberFormatInfo)); + ArgumentNullException.ThrowIfNull(currency, nameof(currency)); + + var mergedNumberFormatInfo = (NumberFormatInfo)numberFormatInfo.Clone(); + mergedNumberFormatInfo.CurrencySymbol = currency.Symbol; + + money = tryParse(s, style, mergedNumberFormatInfo, currency.IsoCode); + + return money.HasValue; + } + + private static Money? tryParse(string s, NumberStyles style, NumberFormatInfo formatProvider, + CurrencyIsoCode currency) + { + decimal amount; + bool result = decimal.TryParse(s, style, formatProvider, out amount); + return result ? new Money(amount, currency) : null; + } } diff --git a/src/NMoneys/NMoneys.csproj b/src/NMoneys/NMoneys.csproj index 21514b2..3befd19 100644 --- a/src/NMoneys/NMoneys.csproj +++ b/src/NMoneys/NMoneys.csproj @@ -31,10 +31,10 @@ - 7.0.0 - 7.0.0.0 - 7.0.0.0 - 7.0.0 + 7.1.0 + 7.1.0.0 + 7.1.0.0 + 7.1.0 diff --git a/tests/NMoneys.Serialization.Tests/BSON/MoneySerializerTester.cs b/tests/NMoneys.Serialization.Tests/BSON/MoneySerializerTester.cs new file mode 100644 index 0000000..0e32c53 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/BSON/MoneySerializerTester.cs @@ -0,0 +1,160 @@ +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; + +using NMoneys.Serialization.BSON; +using NMoneys.Serialization.Tests.Support; +using Testing.Commons.Serialization; + +namespace NMoneys.Serialization.Tests.BSON; + +[TestFixture] +public class MoneySerializerTester +{ + + # region serialization + + [Test] + public void Serialize_DefaultConvention_PascalCasedAndNumericCurrency() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneySerializer(); + string json = toSerialize.ToJson(serializer: subject).Compact(); + + Assert.That(json, Is.EqualTo("{'Amount':NumberDecimal('14.3'),'Currency':963}".Jsonify())); + } + + #endregion + + #region deserialization + + [Test] + public void Deserialize_PascalCasedAndNumericCurrency() + { + string json = "{'Amount':NumberDecimal('14.3'),'Currency':963}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneySerializer(); + Money deserialized = subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(json))); + + Assert.That(deserialized, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneySerializer(); + var json = original.ToJson(serializer: subject); + Money deserialized = subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(json))); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + #endregion + + #region error handling + + [Test] + public void Deserialize_NotAnObject_Exception() + { + string notAJsonObject = "'str'".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(notAJsonObject))), + Throws.InstanceOf() + .With.Message.Contains("CurrentBsonType is Document") + .And.Message.Contains("CurrentBsonType is String")); + } + + [Test] + public void Deserialize_MissingAmount_Exception() + { + string missingAmount = "{}".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(missingAmount))), + Throws.InstanceOf() + .With.Message.Contains("when State is Name") + .And.Message.Contains("not when State is EndOfDocument")); + } + + [Test] + public void Deserialize_NonNumericAmount_Exception() + { + string nonNumericAmount = "{'Amount':[]}".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(nonNumericAmount))), + Throws.InstanceOf() + .With.Message.Contains("Cannot extract a monetary amount out of") + .And.Message.Contains("'Array'")); + } + + [Test] + public void Deserialize_CaseDoesNotMatch_Exception() + { + string caseMismatch = "{'amount':'1','currency':963}".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(caseMismatch))), + Throws.InstanceOf() + .With.Message.Contains("element name to be 'Amount'") + .And.Message.Contains("not 'amount'")); + } + + [Test] + public void Deserialize_MissingCurrency_Exception() + { + string missingCurrency = "{'Amount':'1','other':1}".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(missingCurrency))), + Throws.InstanceOf() + .With.Message.Contains("element name to be 'Currency'")); + } + + [Test] + public void Deserialize_MismatchCurrencyFormat_DoesNotMatter() + { + string currencyFormatMismatch = "{'Amount':'2','Currency':'XTS'}".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(currencyFormatMismatch))), + Throws.Nothing); + } + + [Test] + public void Deserialize_FunkyCurrencyValueCasing_DoesNotMatter() + { + string funkyCurrencyValue = "{'Amount':'1','Currency':'XtS'}".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(funkyCurrencyValue))), + Throws.Nothing); + } + + [Test] + public void Deserialize_UndefinedCurrency_Exception() + { + string undefinedCurrency = "{'Amount':'1','Currency':1}".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(undefinedCurrency))), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_PoorlyConstructedJson_Exception() + { + string missingObjectClose = "{'Amount':'1','Currency':1".Jsonify(); + var subject = new MoneySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(missingObjectClose))), + Throws.InstanceOf()); + } + + #endregion +} diff --git a/tests/NMoneys.Serialization.Tests/BSON/MoneySerializerWithCustomConventionsTester.cs b/tests/NMoneys.Serialization.Tests/BSON/MoneySerializerWithCustomConventionsTester.cs new file mode 100644 index 0000000..2164ec7 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/BSON/MoneySerializerWithCustomConventionsTester.cs @@ -0,0 +1,162 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using NMoneys.Serialization.BSON; +using NMoneys.Serialization.Tests.Support; +using Testing.Commons.Serialization; + +namespace NMoneys.Serialization.Tests.BSON; + +[TestFixture, Explicit("cannot change conventions mid-test :-(")] +public class MoneySerializerWithCustomConventionsTester +{ + [OneTimeSetUp] + public void RegisterSerializer() + { + var subject = new MoneySerializer(); + BsonSerializer.RegisterSerializer(subject); + ConventionPack custom = new() + { + new CamelCaseElementNameConvention(), + new EnumRepresentationConvention(BsonType.String) + }; + ConventionRegistry.Register(nameof(custom), custom, _ => true); + } + + #region serialize + + [Test] + public void Serialize_CustomPolicyAndEnumFormat_FollowsCaseAndCurrencyFormat() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + string json = toSerialize.ToJson().Compact(); + + Assert.That(json, Is.EqualTo("{'amount':NumberDecimal('14.3'),'currency':'XTS'}".Jsonify())); + } + + [Test] + public void Serialize_Null_DefaultNullable() + { + Money? @null = default(Money?); + + string json = @null.ToJson().Compact(); + Assert.That(json, Is.EqualTo("null")); + } + + [Test] + public void Serialize_Record_FollowsConfiguration() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyRecord("s", money, 42); + + string json = toSerialize.ToJson().Compact(); + + Assert.That(json, Is.EqualTo("{'s':'s','m':{'amount':NumberDecimal('14.3'),'currency':'XTS'},'n':42}".Jsonify())); + } + + [Test] + public void Serialize_Container_FollowsConfiguration() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyContainer + { + S = "s", + M = money, + N = 42 + }; + + string json = toSerialize.ToJson().Compact(); + + Assert.That(json, Is.EqualTo("{'s':'s','m':{'amount':NumberDecimal('14.3'),'currency':'XTS'},'n':42}".Jsonify())); + } + + [Test] + public void Serialize_NullableContainer_FollowsConfiguration() + { + var toSerialize = new NullableMoneyContainer + { + N = 42 + }; + + string json = toSerialize.ToJson().Compact(); + + Assert.That(json, Is.EqualTo("{'s':null,'m':null,'n':42}".Jsonify())); + } + + [Test] + public void Serialize_NullableRecord_FollowsConfiguration() + { + var toSerialize = new NullableMoneyRecord(null, null, 42); + + + string json = toSerialize.ToJson().Compact(); + + Assert.That(json, Is.EqualTo("{'s':null,'m':null,'n':42}".Jsonify())); + } + + #endregion + + #region deserialize + + [Test] + public void Deserialize_CustomPolicyAndEnumFormat_FollowsCaseAndCurrencyFormat() + { + string json = "{'amount':NumberDecimal('14.3'),'currency':'xts'}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + Money actual = BsonSerializer.Deserialize(json); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomPolicyAndEnumFormat_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + string json = original.ToJson().Compact(); + + var deserialized = BsonSerializer.Deserialize(json); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_Record() + { + string json = "{'s':'s','m':{'amount':NumberDecimal('14.3'),'currency':'XTS'},'n':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var expected = new MoneyRecord("s", money, 42); + + MoneyRecord actual = BsonSerializer.Deserialize(json); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_Container() + { + string json = "{'s':'s','m':{'amount':NumberDecimal('14.3'),'currency':'XTS'},'n':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + + var actual = BsonSerializer.Deserialize(json); + + Assert.That(actual, Has.Property("S").EqualTo("s")); + Assert.That(actual, Has.Property("M").EqualTo(money)); + Assert.That(actual, Has.Property("N").EqualTo(42)); + } + + [Test] + public void Deserialize_NullableRecord() + { + string json = "{'s':null,'m':null,'n':42}".Jsonify(); + var expected = new NullableMoneyRecord(null, null, 42); + + var actual = BsonSerializer.Deserialize(json); + + Assert.That(actual, Is.EqualTo(expected)); + } + + #endregion +} diff --git a/tests/NMoneys.Serialization.Tests/BSON/QuantitySerializerTester.cs b/tests/NMoneys.Serialization.Tests/BSON/QuantitySerializerTester.cs new file mode 100644 index 0000000..93780c2 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/BSON/QuantitySerializerTester.cs @@ -0,0 +1,131 @@ +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; + +using NMoneys.Serialization.BSON; +using NMoneys.Serialization.Tests.Support; +using Testing.Commons.Serialization; + +namespace NMoneys.Serialization.Tests.BSON; + +[TestFixture] +public class QuantitySerializerTester +{ + + # region serialization + + [Test] + public void Serialize_DefaultConvention_QuantityString() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantitySerializer(); + string json = toSerialize.ToJson(serializer: subject); + + Assert.That(json, Is.EqualTo("'XTS 14.3'".Jsonify())); + } + + #endregion + + #region deserialization + + [Test] + public void Deserialize_QuantityString_PropsSet() + { + string json = "'XTS 14.3'".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantitySerializer(); + Money deserialized = subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(json))); + + Assert.That(deserialized, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantitySerializer(); + var json = original.ToJson(serializer: subject); + Money deserialized = subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(json))); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + #endregion + + #region error handling + + [Test] + public void Deserialize_MissingAmount_Exception() + { + string missingAmount = "'XTS'".Jsonify(); + var subject = new QuantitySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(missingAmount))), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_NonNumericAmount_Exception() + { + string nonNumericAmount = "'XTS lol'".Jsonify(); + var subject = new QuantitySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(nonNumericAmount))), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_MissingCurrency_Exception() + { + string missingCurrency = "'42'".Jsonify(); + var subject = new QuantitySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(missingCurrency))), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_MismatchCurrencyFormat_DoesNotMatter() + { + string currencyFormatMismatch = "'963 42'".Jsonify(); + var subject = new QuantitySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(currencyFormatMismatch))), + Throws.Nothing); + } + + [Test] + public void Deserialize_FunkyCurrencyValueCasing_Exception() + { + string funkyCurrencyValue = "'XtS 42'".Jsonify(); + var subject = new QuantitySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(funkyCurrencyValue))), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_UndefinedCurrency_Exception() + { + string undefinedCurrency = "'LOL 42'}".Jsonify(); + var subject = new QuantitySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(undefinedCurrency))), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_PoorlyConstructedJson_Exception() + { + string missingObjectClose = "'XTS 42".Jsonify(); + var subject = new QuantitySerializer(); + + Assert.That(()=> subject.Deserialize(BsonDeserializationContext.CreateRoot(new JsonReader(missingObjectClose))), + Throws.InstanceOf()); + } + + #endregion +} diff --git a/tests/NMoneys.Serialization.Tests/BSON/QuantityTester.cs b/tests/NMoneys.Serialization.Tests/BSON/QuantityTester.cs new file mode 100644 index 0000000..6c9fdb5 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/BSON/QuantityTester.cs @@ -0,0 +1,57 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using NMoneys.Extensions; +using NMoneys.Serialization.Tests.Support; +using Testing.Commons.Serialization; + +namespace NMoneys.Serialization.Tests.BSON; + +[TestFixture] +public class QuantityTester +{ + [Test] + public void CustomSerializeDto_NoNeedOfCustomSerializer() + { + var dto = new Dto { S = "str", M = new MonetaryQuantity(50m, CurrencyIsoCode.EUR) }; + string json = dto.ToJson().Compact(); + Assert.That(json, Is.EqualTo("{'S':'str','M':{'Amount':'50','Currency':978}}".Jsonify())); + } + + [Test] + public void CustomSerializeDtr_NoNeedOfCustomSerializer() + { + var dtr = new Dtr("str", new MonetaryQuantity(50m, CurrencyIsoCode.EUR)); + string json = dtr.ToJson().Compact(); + Assert.That(json, Is.EqualTo("{'S':'str','M':{'Amount':'50','Currency':978}}".Jsonify())); + } + + [Test] + public void CustomDeserializeDto_NoNeedOfCustomSerializer() + { + string json = "{'S':'str','M':{'Amount':'50','Currency':978}}"; + + Dto dto = BsonSerializer.Deserialize(json); + + Assert.That(dto, Has.Property("S").EqualTo("str")); + Assert.That(dto?.M, Has.Property("Amount").EqualTo(50m).And + .Property("Currency").EqualTo(CurrencyIsoCode.EUR)); + + // convert to money for operations + Money m = (Money)dto!.M; + Assert.That(m, Is.EqualTo(50m.Eur())); + } + + [Test] + public void CustomDeserializeDtr_NoNeedOfCustomSerializer() + { + string json = "{'S':'str','M':{'Amount':'50','Currency':978}}"; + Dtr? dtr = BsonSerializer.Deserialize(json); + Assert.That(dtr, Has.Property("S").EqualTo("str")); + Assert.That(dtr?.M, Has.Property("Amount").EqualTo(50m).And + .Property("Currency").EqualTo(CurrencyIsoCode.EUR)); + + // convert to money for operations + Money m = (Money)dtr!.M; + Assert.That(m, Is.EqualTo(50m.Eur())); + } +} diff --git a/tests/NMoneys.Serialization.Tests/EFCore/JsonConverterTester.cs b/tests/NMoneys.Serialization.Tests/EFCore/JsonConverterTester.cs new file mode 100644 index 0000000..b15197c --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/EFCore/JsonConverterTester.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using NMoneys.Serialization.EFCore; +using NMoneys.Serialization.Tests.EFCore.Support; +using NMoneys.Serialization.Tests.Support; +using NMoneys.Serialization.Text_Json; +using Testing.Commons.Serialization; + +using JsonConverter = NMoneys.Serialization.EFCore.JsonConverter; + +namespace NMoneys.Serialization.Tests.EFCore; + +[TestFixture] +public class JsonConverterTester +{ + private FileInfo? _dbFile; + + private JsonSerializerOptions JsonOptions { get; } = new() + { + Converters = { new MoneyConverter(), new JsonStringEnumConverter() }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + [OneTimeSetUp] + public void SetupDb() + { + _dbFile = new FileInfo("JsonPersistence.sdf"); + } + + [OneTimeTearDown] + public void TearDownDb() + { + _dbFile?.Delete(); + } + + [Test] + public void SaveEntity_SavesAsQuantityValue() + { + using var context = new JsonDbContext(_dbFile!, new JsonConverter(JsonOptions), new MoneyComparer()); + context.Database.EnsureCreated(); + + var m = new Money(43.75m, CurrencyIsoCode.XTS); + var id = new Guid(new string('1', 32)); + var model = new MoneyModel + { + Id = id, + M = m, + N = 42 + }; + context.Models.Add(model); + context.SaveChanges(); + + FormattableString sql = $"SELECT M FROM Models WHERE ID = {id.ToString()}"; + var queried = context.Database.SqlQuery(sql) + .ToArray() + .Single(); + Assert.That(queried, Is.EqualTo("{'amount':43.75,'currency':'XTS'}".Jsonify())); + } + + [Test] + public void SaveEntity_AsQuantity_CanRoundtrip() + { + using var context = new JsonDbContext(_dbFile!, new JsonConverter(JsonOptions), new MoneyComparer()); + context.Database.EnsureCreated(); + + var m = new Money(43.75m, CurrencyIsoCode.XTS); + var id = new Guid(new string('2', 32)); + var model = new MoneyModel + { + Id = id, + M = m, + N = 42 + }; + context.Models.Add(model); + context.SaveChanges(); + + MoneyModel queried = context.Models.Single(mm => mm.Id == id); + Assert.That(queried.M, Is.EqualTo(m)); + } + + [Test] + public void SaveNullableEntity_SavesAsNull() + { + using var context = new JsonDbContext(_dbFile!, new JsonConverter(JsonOptions), new MoneyComparer()); + context.Database.EnsureCreated(); + + var id = new Guid(new string('3', 32)); + var model = new NullableMoneyModel + { + Id = id, + N = 42 + }; + context.NullableModels.Add(model); + context.SaveChanges(); + + FormattableString sql = $"SELECT M FROM NullableModels WHERE Id = {id.ToString()}"; + var queried = context.Database.SqlQuery(sql) + .ToArray() + .Single(); + Assert.That(queried, Is.Null); + } + + [Test] + public void SaveNullableEntity_AsQuantity_CanRoundtrip() + { + using var context = new JsonDbContext(_dbFile!, new JsonConverter(JsonOptions), new MoneyComparer()); + context.Database.EnsureCreated(); + + var id = new Guid(new string('4', 32)); + var model = new NullableMoneyModel + { + Id = id, + N = 42 + }; + context.NullableModels.Add(model); + context.SaveChanges(); + + NullableMoneyModel queried = context.NullableModels.Single(mm => mm.Id == id); + Assert.That(queried.M, Is.Null); + } +} diff --git a/tests/NMoneys.Serialization.Tests/EFCore/QuantityConverterTester.cs b/tests/NMoneys.Serialization.Tests/EFCore/QuantityConverterTester.cs new file mode 100644 index 0000000..bc1f850 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/EFCore/QuantityConverterTester.cs @@ -0,0 +1,109 @@ +using Microsoft.EntityFrameworkCore; +using NMoneys.Serialization.EFCore; +using NMoneys.Serialization.Tests.EFCore.Support; +using NMoneys.Serialization.Tests.Support; + +namespace NMoneys.Serialization.Tests.EFCore; + +[TestFixture] +public class QuantityConverterTester +{ + private FileInfo? _dbFile; + [OneTimeSetUp] + public void SetupDb() + { + _dbFile = new FileInfo("QuantityPersistence.sdf"); + } + + [OneTimeTearDown] + public void TearDownDb() + { + _dbFile?.Delete(); + } + + [Test] + public void SaveEntity_SavesAsQuantityValue() + { + using var context = new QuantityDbContext(_dbFile!, new QuantityConverter(), new MoneyComparer()); + context.Database.EnsureCreated(); + + var m = new Money(43.75m, CurrencyIsoCode.XTS); + var id = new Guid(new string('1', 32)); + var model = new MoneyModel + { + Id = id, + M = m, + N = 42 + }; + context.Models.Add(model); + context.SaveChanges(); + + FormattableString sql = $"SELECT M FROM Models WHERE Id = {id.ToString()}"; + var queried = context.Database.SqlQuery(sql) + .ToArray() + .Single(); + Assert.That(queried, Is.EqualTo("XTS 43.75")); + } + + [Test] + public void SaveEntity_AsQuantity_CanRoundtrip() + { + using var context = new QuantityDbContext(_dbFile!, new QuantityConverter(), new MoneyComparer()); + context.Database.EnsureCreated(); + + var m = new Money(43.75m, CurrencyIsoCode.XTS); + var id = new Guid(new string('2', 32)); + var model = new MoneyModel + { + Id = id, + M = m, + N = 42 + }; + context.Models.Add(model); + context.SaveChanges(); + + MoneyModel queried = context.Models.Single(mm => mm.Id == id); + Assert.That(queried.M, Is.EqualTo(m)); + } + + [Test] + public void SaveNullableEntity_SavesAsNull() + { + using var context = new QuantityDbContext(_dbFile!, new QuantityConverter(), new MoneyComparer()); + context.Database.EnsureCreated(); + + var id = new Guid(new string('3', 32)); + var model = new NullableMoneyModel + { + Id = id, + N = 42 + }; + context.NullableModels.Add(model); + context.SaveChanges(); + + FormattableString sql = $"SELECT M FROM NullableModels WHERE Id = {id.ToString()}"; + var queried = context.Database.SqlQuery(sql) + .ToArray() + .Single(); + Assert.That(queried, Is.Null); + } + + [Test] + public void SaveNullableEntity_AsQuantity_CanRoundtrip() + { + using var context = new QuantityDbContext(_dbFile!, new QuantityConverter(), new MoneyComparer()); + context.Database.EnsureCreated(); + + var id = new Guid(new string('4', 32)); + var model = new NullableMoneyModel + { + Id = id, + N = 42 + }; + context.NullableModels.Add(model); + context.SaveChanges(); + + NullableMoneyModel queried = context.NullableModels.Single(mm => mm.Id == id); + Assert.That(queried.M, Is.Null); + } +} diff --git a/tests/NMoneys.Serialization.Tests/EFCore/Support/JsonDbContext.cs b/tests/NMoneys.Serialization.Tests/EFCore/Support/JsonDbContext.cs new file mode 100644 index 0000000..70dd151 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/EFCore/Support/JsonDbContext.cs @@ -0,0 +1,43 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NMoneys.Serialization.EFCore; +using NMoneys.Serialization.Tests.Support; + +namespace NMoneys.Serialization.Tests.EFCore.Support; + +public class JsonDbContext : DbContext +{ + private readonly ValueComparer _comparer; + private readonly string _connectionString; + private readonly ValueConverter _converter; + public JsonDbContext(FileInfo dbFile, JsonConverter converter, ValueComparer comparer) + { + _comparer = comparer; + _converter = converter; + var builder = new SqliteConnectionStringBuilder + { + DataSource = dbFile.Name + }; + _connectionString = builder.ConnectionString; + } + public DbSet Models { get; set; } + public DbSet NullableModels { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + base.OnConfiguring(options); + + options.UseSqlite(_connectionString); + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity().HasKey(e => e.Id); + builder.Entity().HasKey(e => e.Id); + builder.Entity().Property(e => e.M).HasConversion(_converter, _comparer); + builder.Entity().Property(e => e.M).HasConversion(_converter, _comparer); + base.OnModelCreating(builder); + } +} diff --git a/tests/NMoneys.Serialization.Tests/EFCore/Support/QuantityDbContext.cs b/tests/NMoneys.Serialization.Tests/EFCore/Support/QuantityDbContext.cs new file mode 100644 index 0000000..684ce2f --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/EFCore/Support/QuantityDbContext.cs @@ -0,0 +1,44 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NMoneys.Serialization.EFCore; +using NMoneys.Serialization.Tests.Support; + +namespace NMoneys.Serialization.Tests.EFCore.Support; + +public class QuantityDbContext : DbContext +{ + private readonly ValueComparer _comparer; + private readonly string _connectionString; + private readonly ValueConverter _converter; + public QuantityDbContext(FileInfo dbFile, QuantityConverter converter, ValueComparer comparer) + { + _comparer = comparer; + _converter = converter; + var builder = new SqliteConnectionStringBuilder + { + DataSource = dbFile.Name + }; + _connectionString = builder.ConnectionString; + } + public DbSet Models { get; set; } + public DbSet NullableModels { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + base.OnConfiguring(options); + + options.UseSqlite(_connectionString); + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity().HasKey(e => e.Id); + builder.Entity().HasKey(e => e.Id); + builder.Entity().Property(e => e.M).HasConversion(_converter, _comparer); + builder.Entity().Property(e => e.M).HasConversion(_converter, _comparer); + base.OnModelCreating(builder); + } + +} diff --git a/tests/NMoneys.Serialization.Tests/Json_NET/DescriptiveMoneyConverterTester.cs b/tests/NMoneys.Serialization.Tests/Json_NET/DescriptiveMoneyConverterTester.cs new file mode 100644 index 0000000..4ed57c0 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Json_NET/DescriptiveMoneyConverterTester.cs @@ -0,0 +1,9 @@ +namespace NMoneys.Serialization.Tests.Json_NET; + +// TODO: lots of tests for errors + +[TestFixture] +public class DescriptiveMoneyConverterTester +{ + +} diff --git a/tests/NMoneys.Serialization.Tests/Json_NET/MoneyConverterTester.cs b/tests/NMoneys.Serialization.Tests/Json_NET/MoneyConverterTester.cs new file mode 100644 index 0000000..da351a3 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Json_NET/MoneyConverterTester.cs @@ -0,0 +1,476 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using NMoneys.Extensions; +using Testing.Commons.Serialization; + +using NMoneys.Serialization.Tests.Support; +using NMoneys.Serialization.Json_NET; + +namespace NMoneys.Serialization.Tests.Json_NET; + +[TestFixture] +public class MoneyConverterTester +{ + [Test, Category("exploratory")] + public void OutOfTheBoxSerialization_BlowsUp() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + Assert.That(()=> JsonConvert.SerializeObject(toSerialize), Throws.InstanceOf()); + } + + # region serialization + + [Test] + public void Serialize_DefaultResolver_PascalCasedAndNumericCurrency() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + string actual = JsonConvert.SerializeObject(toSerialize, subject); + + Assert.That(actual, Is.EqualTo("{'Amount':14.3,'Currency':963}".Jsonify())); + } + + [Test] + public void Serialize_DefaultResolverWithForcedStringCurrency_PascalCasedAndStringCurrency() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + string actual = JsonConvert.SerializeObject(toSerialize, subject); + + Assert.That(actual, Is.EqualTo("{'Amount':14.3,'Currency':'XTS'}".Jsonify())); + } + + [Test] + public void Serialize_CustomResolverAndEnumFormat_FollowsCaseAndCurrencyFormat() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + string actual = JsonConvert.SerializeObject(toSerialize, settings); + + Assert.That(actual, Is.EqualTo("{'amount':14.3,'currency':'XTS'}".Jsonify())); + } + + [Test] + public void Serialize_CustomResolverAndForcedString_FollowsCaseAndOverridesCurrencyFormat() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { subject } + }; + string actual = JsonConvert.SerializeObject(toSerialize, settings); + + Assert.That(actual, Is.EqualTo("{'amount':14.3,'currency':'XTS'}".Jsonify())); + } + + [Test] + public void Serialize_Null_DefaultNullable() + { + Money? @null = default(Money?); + + var subject = new MoneyConverter(); + + string actual = JsonConvert.SerializeObject(@null, subject); + Assert.That(actual, Is.EqualTo("null")); + } + + [Test] + public void Serialize_DefaultResolverRecord_PascalCasedAndNumericCurrency() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyRecord("s", money, 42); + + var subject = new MoneyConverter(); + string actual = JsonConvert.SerializeObject(toSerialize, subject); + + Assert.That(actual, Is.EqualTo("{'S':'s','M':{'Amount':14.3,'Currency':963},'N':42}".Jsonify())); + } + + [Test] + public void Serialize_CustomContainer_FollowsConfiguration() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyContainer + { + S = "s", + M = money, + N = 42 + }; + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + string actual = JsonConvert.SerializeObject(toSerialize, settings); + + Assert.That(actual, Is.EqualTo("{'s':'s','m':{'amount':14.3,'currency':'XTS'},'n':42}".Jsonify())); + } + + [Test] + public void Serialize_DefaultResolverNullableContainer_PascalCasedAndNumericCurrency() + { + var toSerialize = new NullableMoneyContainer + { + N = 42 + }; + + var subject = new MoneyConverter(); + string actual = JsonConvert.SerializeObject(toSerialize, subject); + + Assert.That(actual, Is.EqualTo("{'S':null,'M':null,'N':42}".Jsonify())); + } + + [Test] + public void Serialize_CustomNullableRecord_FollowsConfiguration() + { + var toSerialize = new NullableMoneyRecord(null, null, 42); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + string actual = JsonConvert.SerializeObject(toSerialize, settings); + + Assert.That(actual, Is.EqualTo("{'s':null,'m':null,'n':42}".Jsonify())); + } + + #endregion + + #region deserialization + + [Test] + public void Deserialize_DefaultResolver_PascalCasedAndNumericCurrency() + { + string json = "{'Amount':14.3,'Currency':963}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + Money deserialized = JsonConvert.DeserializeObject(json, subject); + + Assert.That(deserialized, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_DefaultResolver_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + string json = JsonConvert.SerializeObject(original, subject); + var deserialized = JsonConvert.DeserializeObject(json, subject); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_DefaultResolverWithForcedStringCurrency_PascalCasedAndStringCurrency() + { + string json = "{'Amount':14.3,'Currency':'XTS'}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + Money deserialized = JsonConvert.DeserializeObject(json, subject); + + Assert.That(deserialized, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_DefaultResolverWithForcedStringCurrency_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + string json = JsonConvert.SerializeObject(original, subject); + var deserialized = JsonConvert.DeserializeObject(json, subject); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_CustomResolverAndEnumFormat_FollowsCaseAndCurrencyFormat() + { + string json = "{'amount':14.3,'currency':'XTS'}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + var actual = JsonConvert.DeserializeObject(json, settings); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomResolverAndEnumFormat_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + string json = JsonConvert.SerializeObject(original, settings); + var deserialized = JsonConvert.DeserializeObject(json, settings); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_CustomResolverAndForcedString_FollowsCaseAndOverridesCurrencyFormat() + { + string json = "{'amount':14.3,'currency':'XTS'}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { subject } + }; + var actual = JsonConvert.DeserializeObject(json, settings); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomResolverAndForcedString_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { subject } + }; + var json = JsonConvert.SerializeObject(original, settings); + var deserialized = JsonConvert.DeserializeObject(json, settings); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_Null_DefaultNullable() + { + var subject = new MoneyConverter(); + + var actual = JsonConvert.DeserializeObject("null", subject); + Assert.That(actual, Is.Null); + } + + [Test] + public void Deserialize_DefaultResolverRecord_PascalCasedAndNumericCurrency() + { + string json = "{'S':'s','M':{'Amount':14.3,'Currency':963},'N':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var expected = new MoneyRecord("s", money, 42); + + var subject = new MoneyConverter(); + MoneyRecord? actual = JsonConvert.DeserializeObject(json, subject); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomContainer_FollowsConfiguration() + { + string json = "{'s':'s','m':{'amount':14.3,'currency':'XTS'},'n':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + var actual = JsonConvert.DeserializeObject(json, settings); + + Assert.That(actual, Has.Property("S").EqualTo("s")); + Assert.That(actual, Has.Property("M").EqualTo(money)); + Assert.That(actual, Has.Property("N").EqualTo(42)); + + } + + [Test] + public void Deserialize_DefaultResolverNullableContainer_PascalCasedAndNumericCurrency() + { + string json = "{'S':null,'M':null,'N':42}".Jsonify(); + + var subject = new MoneyConverter(); + var actual = JsonConvert.DeserializeObject(json, subject); + + Assert.That(actual, Has.Property("S").Null); + Assert.That(actual, Has.Property("M").Null); + Assert.That(actual, Has.Property("N").EqualTo(42)); + } + + [Test] + public void Deserialize_CustomNullableRecord_FollowsConfiguration() + { + string json = "{'s':null,'m':null,'n':42}".Jsonify(); + var expected = new NullableMoneyRecord(null, null, 42); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + var actual = JsonConvert.DeserializeObject(json, settings); + + Assert.That(actual, Is.EqualTo(expected)); + } + + #region error handling + + [Test] + public void Deserialize_DefaultNotAnObject_Exception() + { + string notAJsonObject = "'str'".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(notAJsonObject, subject), + Throws.InstanceOf() + .With.Message.Contains("token 'Object'") + .And.Message.Contains("'String'")); + } + + [Test] + public void Deserialize_DefaultMissingAmount_Exception() + { + string missingAmount = "{}".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(missingAmount, subject), + Throws.InstanceOf() + .With.Message.Contains("Missing property") + .And.Message.Contains("'Amount'")); + } + + [Test] + public void Deserialize_DefaultNonNumericAmount_Exception() + { + string nonNumericAmount = "{'Amount':'lol'}".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(nonNumericAmount, subject), + Throws.InstanceOf() + .With.Message.Contains("input string 'lol'")); + } + + [Test] + public void Deserialize_CaseDoesNotMatchContract_DoesntMatter() + { + string caseMismatch = "{'Amount':1,'currency':963}".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(caseMismatch, subject), + Throws.Nothing); + var deserialized = JsonConvert.DeserializeObject(caseMismatch, subject); + Assert.That(deserialized, Is.EqualTo(1m.Xts())); + } + + [Test] + public void Deserialize_MissingCurrency_ExceptionIgnoringCasing() + { + string missingCurrency = "{'amount':1}".Jsonify(); + var subject = new MoneyConverter(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = { subject } + }; + Assert.That(()=> JsonConvert.DeserializeObject(missingCurrency, settings), + Throws.InstanceOf() + .With.Message.Contains("Missing property") + .And.Message.Contains("'Currency'"), "no camel-casing"); + } + + [Test] + public void Deserialize_MismatchCurrencyFormat_DoesNotMatter() + { + string currencyFormatMismatch = "{'amount':1,'currency':'XTS'}".Jsonify(); + var subject = new MoneyConverter(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = { subject } + }; + Assert.That(()=> JsonConvert.DeserializeObject(currencyFormatMismatch, settings), + Throws.Nothing); + + Money deserialized = JsonConvert.DeserializeObject(currencyFormatMismatch, settings); + Assert.That(deserialized, Is.EqualTo(1m.Xts())); + } + + [Test] + public void Deserialize_FunkyCurrencyValueCasing_DoesNotMatter() + { + string funkyCurrencyValue = "{'amount':1,'currency':'XtS'}".Jsonify(); + var subject = new MoneyConverter(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = { subject } + }; + Assert.That(()=> JsonConvert.DeserializeObject(funkyCurrencyValue, settings), + Throws.Nothing); + + Money deserialized = JsonConvert.DeserializeObject(funkyCurrencyValue, settings); + Assert.That(deserialized, Is.EqualTo(1m.Xts())); + } + + [Test] + public void Deserialize_UndefinedCurrency_Exception() + { + string undefinedCurrency = "{'amount':1,'currency':1}".Jsonify(); + var subject = new MoneyConverter(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = { subject } + }; + Assert.That(()=> JsonConvert.DeserializeObject(undefinedCurrency, settings), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_PoorlyConstructedJson_Exception() + { + string missingObjectClose = "{'amount':1,'currency':1".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(missingObjectClose, subject), + Throws.InstanceOf()); + } + + #endregion + + #endregion +} diff --git a/tests/NMoneys.Serialization.Tests/Json_NET/QuantityConverterTester.cs b/tests/NMoneys.Serialization.Tests/Json_NET/QuantityConverterTester.cs new file mode 100644 index 0000000..323571f --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Json_NET/QuantityConverterTester.cs @@ -0,0 +1,468 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using NMoneys.Extensions; +using Testing.Commons.Serialization; + +using NMoneys.Serialization.Tests.Support; +using NMoneys.Serialization.Json_NET; + +namespace NMoneys.Serialization.Tests.Json_NET; + +[TestFixture] +public class QuantityConverterTester +{ + # region serialization + + [Test] + public void Serialize_DefaultResolver_PascalCasedAndNumericCurrency() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantityConverter(); + string actual = JsonConvert.SerializeObject(toSerialize, subject); + + Assert.That(actual, Is.EqualTo("{'Amount':14.3,'Currency':963}".Jsonify())); + } + + [Test] + public void Serialize_DefaultResolverWithForcedStringCurrency_PascalCasedAndStringCurrency() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + string actual = JsonConvert.SerializeObject(toSerialize, subject); + + Assert.That(actual, Is.EqualTo("{'Amount':14.3,'Currency':'XTS'}".Jsonify())); + } + + [Test] + public void Serialize_CustomResolverAndEnumFormat_FollowsCaseAndCurrencyFormat() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + string actual = JsonConvert.SerializeObject(toSerialize, settings); + + Assert.That(actual, Is.EqualTo("{'amount':14.3,'currency':'XTS'}".Jsonify())); + } + + [Test] + public void Serialize_CustomResolverAndForcedString_FollowsCaseAndOverridesCurrencyFormat() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { subject } + }; + string actual = JsonConvert.SerializeObject(toSerialize, settings); + + Assert.That(actual, Is.EqualTo("{'amount':14.3,'currency':'XTS'}".Jsonify())); + } + + [Test] + public void Serialize_Null_DefaultNullable() + { + Money? @null = default(Money?); + + var subject = new MoneyConverter(); + + string actual = JsonConvert.SerializeObject(@null, subject); + Assert.That(actual, Is.EqualTo("null")); + } + + [Test] + public void Serialize_DefaultResolverRecord_PascalCasedAndNumericCurrency() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyRecord("s", money, 42); + + var subject = new MoneyConverter(); + string actual = JsonConvert.SerializeObject(toSerialize, subject); + + Assert.That(actual, Is.EqualTo("{'S':'s','M':{'Amount':14.3,'Currency':963},'N':42}".Jsonify())); + } + + [Test] + public void Serialize_CustomContainer_FollowsConfiguration() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyContainer + { + S = "s", + M = money, + N = 42 + }; + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + string actual = JsonConvert.SerializeObject(toSerialize, settings); + + Assert.That(actual, Is.EqualTo("{'s':'s','m':{'amount':14.3,'currency':'XTS'},'n':42}".Jsonify())); + } + + [Test] + public void Serialize_DefaultResolverNullableContainer_PascalCasedAndNumericCurrency() + { + var toSerialize = new NullableMoneyContainer + { + N = 42 + }; + + var subject = new MoneyConverter(); + string actual = JsonConvert.SerializeObject(toSerialize, subject); + + Assert.That(actual, Is.EqualTo("{'S':null,'M':null,'N':42}".Jsonify())); + } + + [Test] + public void Serialize_CustomNullableRecord_FollowsConfiguration() + { + var toSerialize = new NullableMoneyRecord(null, null, 42); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + string actual = JsonConvert.SerializeObject(toSerialize, settings); + + Assert.That(actual, Is.EqualTo("{'s':null,'m':null,'n':42}".Jsonify())); + } + + #endregion + + #region deserialization + + [Test] + public void Deserialize_DefaultResolver_PascalCasedAndNumericCurrency() + { + string json = "{'Amount':14.3,'Currency':963}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + Money deserialized = JsonConvert.DeserializeObject(json, subject); + + Assert.That(deserialized, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_DefaultResolver_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + string json = JsonConvert.SerializeObject(original, subject); + var deserialized = JsonConvert.DeserializeObject(json, subject); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_DefaultResolverWithForcedStringCurrency_PascalCasedAndStringCurrency() + { + string json = "{'Amount':14.3,'Currency':'XTS'}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + Money deserialized = JsonConvert.DeserializeObject(json, subject); + + Assert.That(deserialized, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_DefaultResolverWithForcedStringCurrency_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + string json = JsonConvert.SerializeObject(original, subject); + var deserialized = JsonConvert.DeserializeObject(json, subject); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_CustomResolverAndEnumFormat_FollowsCaseAndCurrencyFormat() + { + string json = "{'amount':14.3,'currency':'XTS'}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + var actual = JsonConvert.DeserializeObject(json, settings); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomResolverAndEnumFormat_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + string json = JsonConvert.SerializeObject(original, settings); + var deserialized = JsonConvert.DeserializeObject(json, settings); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_CustomResolverAndForcedString_FollowsCaseAndOverridesCurrencyFormat() + { + string json = "{'amount':14.3,'currency':'XTS'}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { subject } + }; + var actual = JsonConvert.DeserializeObject(json, settings); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomResolverAndForcedString_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(true); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { subject } + }; + var json = JsonConvert.SerializeObject(original, settings); + var deserialized = JsonConvert.DeserializeObject(json, settings); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_Null_DefaultNullable() + { + var subject = new MoneyConverter(); + + var actual = JsonConvert.DeserializeObject("null", subject); + Assert.That(actual, Is.Null); + } + + [Test] + public void Deserialize_DefaultResolverRecord_PascalCasedAndNumericCurrency() + { + string json = "{'S':'s','M':{'Amount':14.3,'Currency':963},'N':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var expected = new MoneyRecord("s", money, 42); + + var subject = new MoneyConverter(); + MoneyRecord? actual = JsonConvert.DeserializeObject(json, subject); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomContainer_FollowsConfiguration() + { + string json = "{'s':'s','m':{'amount':14.3,'currency':'XTS'},'n':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + var actual = JsonConvert.DeserializeObject(json, settings); + + Assert.That(actual, Has.Property("S").EqualTo("s")); + Assert.That(actual, Has.Property("M").EqualTo(money)); + Assert.That(actual, Has.Property("N").EqualTo(42)); + + } + + [Test] + public void Deserialize_DefaultResolverNullableContainer_PascalCasedAndNumericCurrency() + { + string json = "{'S':null,'M':null,'N':42}".Jsonify(); + + var subject = new MoneyConverter(); + var actual = JsonConvert.DeserializeObject(json, subject); + + Assert.That(actual, Has.Property("S").Null); + Assert.That(actual, Has.Property("M").Null); + Assert.That(actual, Has.Property("N").EqualTo(42)); + } + + [Test] + public void Deserialize_CustomNullableRecord_FollowsConfiguration() + { + string json = "{'s':null,'m':null,'n':42}".Jsonify(); + var expected = new NullableMoneyRecord(null, null, 42); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter(), subject } + }; + var actual = JsonConvert.DeserializeObject(json, settings); + + Assert.That(actual, Is.EqualTo(expected)); + } + + #region error handling + + [Test] + public void Deserialize_DefaultNotAnObject_Exception() + { + string notAJsonObject = "'str'".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(notAJsonObject, subject), + Throws.InstanceOf() + .With.Message.Contains("token 'Object'") + .And.Message.Contains("'String'")); + } + + [Test] + public void Deserialize_DefaultMissingAmount_Exception() + { + string missingAmount = "{}".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(missingAmount, subject), + Throws.InstanceOf() + .With.Message.Contains("Missing property") + .And.Message.Contains("'Amount'")); + } + + [Test] + public void Deserialize_DefaultNonNumericAmount_Exception() + { + string nonNumericAmount = "{'Amount':'lol'}".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(nonNumericAmount, subject), + Throws.InstanceOf() + .With.Message.Contains("input string 'lol'")); + } + + [Test] + public void Deserialize_CaseDoesNotMatchContract_DoesntMatter() + { + string caseMismatch = "{'Amount':1,'currency':963}".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(caseMismatch, subject), + Throws.Nothing); + var deserialized = JsonConvert.DeserializeObject(caseMismatch, subject); + Assert.That(deserialized, Is.EqualTo(1m.Xts())); + } + + [Test] + public void Deserialize_MissingCurrency_ExceptionIgnoringCasing() + { + string missingCurrency = "{'amount':1}".Jsonify(); + var subject = new MoneyConverter(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = { subject } + }; + Assert.That(()=> JsonConvert.DeserializeObject(missingCurrency, settings), + Throws.InstanceOf() + .With.Message.Contains("Missing property") + .And.Message.Contains("'Currency'"), "no camel-casing"); + } + + [Test] + public void Deserialize_MismatchCurrencyFormat_DoesNotMatter() + { + string currencyFormatMismatch = "{'amount':1,'currency':'XTS'}".Jsonify(); + var subject = new MoneyConverter(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = { subject } + }; + Assert.That(()=> JsonConvert.DeserializeObject(currencyFormatMismatch, settings), + Throws.Nothing); + + Money deserialized = JsonConvert.DeserializeObject(currencyFormatMismatch, settings); + Assert.That(deserialized, Is.EqualTo(1m.Xts())); + } + + [Test] + public void Deserialize_FunkyCurrencyValueCasing_DoesNotMatter() + { + string funkyCurrencyValue = "{'amount':1,'currency':'XtS'}".Jsonify(); + var subject = new MoneyConverter(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = { subject } + }; + Assert.That(()=> JsonConvert.DeserializeObject(funkyCurrencyValue, settings), + Throws.Nothing); + + Money deserialized = JsonConvert.DeserializeObject(funkyCurrencyValue, settings); + Assert.That(deserialized, Is.EqualTo(1m.Xts())); + } + + [Test] + public void Deserialize_UndefinedCurrency_Exception() + { + string undefinedCurrency = "{'amount':1,'currency':1}".Jsonify(); + var subject = new MoneyConverter(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = { subject } + }; + Assert.That(()=> JsonConvert.DeserializeObject(undefinedCurrency, settings), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_PoorlyConstructedJson_Exception() + { + string missingObjectClose = "{'amount':1,'currency':1".Jsonify(); + var subject = new MoneyConverter(); + + Assert.That(()=> JsonConvert.DeserializeObject(missingObjectClose, subject), + Throws.InstanceOf()); + } + + #endregion + + #endregion +} diff --git a/tests/NMoneys.Serialization.Tests/Json_NET/QuantityTester.cs b/tests/NMoneys.Serialization.Tests/Json_NET/QuantityTester.cs new file mode 100644 index 0000000..872b481 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Json_NET/QuantityTester.cs @@ -0,0 +1,72 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using NMoneys.Extensions; +using NMoneys.Serialization.Tests.Support; +using Testing.Commons.Serialization; + +namespace NMoneys.Serialization.Tests.Json_NET; + +[TestFixture] +public class QuantityTester +{ + [Test] + public void CustomSerializeDto_NoNeedOfCustomSerializer() + { + var dto = new Dto { S = "str", M = new MonetaryQuantity(50m, CurrencyIsoCode.EUR) }; + string json = JsonConvert.SerializeObject(dto, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = {new StringEnumConverter() } + }); + Assert.That(json, Is.EqualTo("{'s':'str','m':{'amount':50.0,'currency':'EUR'}}".Jsonify())); + } + + [Test] + public void CustomSerializeDtr_NoNeedOfCustomSerializer() + { + var dtr = new Dtr("str", new MonetaryQuantity(50m, CurrencyIsoCode.EUR)); + string json = JsonConvert.SerializeObject(dtr, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = {new StringEnumConverter() } + }); + Assert.That(json, Is.EqualTo("{'s':'str','m':{'amount':50.0,'currency':'EUR'}}".Jsonify())); + } + + [Test] + public void CustomDeserializeDto_NoNeedOfCustomSerializer() + { + string json = "{'s':'str','m':{'amount':50,'currency':'EUR'}}"; + Dto? dto = JsonConvert.DeserializeObject(json, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = {new StringEnumConverter() } + }); + Assert.That(dto, Has.Property("S").EqualTo("str")); + Assert.That(dto?.M, Has.Property("Amount").EqualTo(50m).And + .Property("Currency").EqualTo(CurrencyIsoCode.EUR)); + + // convert to money for operations + Money m = (Money)dto!.M; + Assert.That(m, Is.EqualTo(50m.Eur())); + } + + [Test] + public void CustomDeserializeDtr_NoNeedOfCustomSerializer() + { + string json = "{'s':'str','m':{'amount':50,'currency':'EUR'}}"; + Dtr? dtr = JsonConvert.DeserializeObject(json, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = {new StringEnumConverter() } + }); + Assert.That(dtr, Has.Property("S").EqualTo("str")); + Assert.That(dtr?.M, Has.Property("Amount").EqualTo(50m).And + .Property("Currency").EqualTo(CurrencyIsoCode.EUR)); + + // convert to money for operations + Money m = (Money)dtr!.M; + Assert.That(m, Is.EqualTo(50m.Eur())); + } +} diff --git a/tests/NMoneys.Serialization.Tests/NMoneys.Serialization.Tests.csproj b/tests/NMoneys.Serialization.Tests/NMoneys.Serialization.Tests.csproj new file mode 100644 index 0000000..cc922bb --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/NMoneys.Serialization.Tests.csproj @@ -0,0 +1,33 @@ + + + + net7.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/NMoneys.Serialization.Tests/Support/TestExtensions.cs b/tests/NMoneys.Serialization.Tests/Support/TestExtensions.cs new file mode 100644 index 0000000..82e6ee6 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Support/TestExtensions.cs @@ -0,0 +1,15 @@ +namespace NMoneys.Serialization.Tests.Support; + +internal static class TestExtensions +{ + /// + /// "Compacts" the resulting JSON from Bson serialization. + /// + /// Removes spaces from a non-indented JSON. + /// + /// JSON without spaces. + public static string Compact(this string json) + { + return json.Replace(" ", string.Empty); + } +} diff --git a/tests/NMoneys.Serialization.Tests/Support/money_containers.cs b/tests/NMoneys.Serialization.Tests/Support/money_containers.cs new file mode 100644 index 0000000..6c51e2d --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Support/money_containers.cs @@ -0,0 +1,25 @@ +using MongoDB.Bson; + +namespace NMoneys.Serialization.Tests.Support; + +public record MoneyRecord(string S, Money M, int N); + +public record MoneyRecordDoc(ObjectId Id, string S, Money M, int N); + +public class MoneyContainer +{ + public string? S { get; set; } + public Money M { get; set; } + + public int N { get; set; } +} + +public class MoneyDoc : MoneyContainer +{ + public ObjectId Id { get; set; } +} + +public class MoneyModel : MoneyContainer +{ + public Guid Id { get; set; } +} diff --git a/tests/NMoneys.Serialization.Tests/Support/nullable_money_containers.cs b/tests/NMoneys.Serialization.Tests/Support/nullable_money_containers.cs new file mode 100644 index 0000000..74701f9 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Support/nullable_money_containers.cs @@ -0,0 +1,25 @@ +using MongoDB.Bson; + +namespace NMoneys.Serialization.Tests.Support; + +public record NullableMoneyRecord(string? S, Money? M, int N); + +public record NullableMoneyRecordDoc(ObjectId Id, string? S, Money? M, int N); + +public class NullableMoneyContainer +{ + public string? S { get; set; } + public Money? M { get; set; } + + public int N { get; set; } +} + +public class NullableMoneyDoc : NullableMoneyContainer +{ + public ObjectId Id { get; set; } +} + +public class NullableMoneyModel : NullableMoneyContainer +{ + public Guid Id { get; set; } +} diff --git a/tests/NMoneys.Serialization.Tests/Support/quantity_containers.cs b/tests/NMoneys.Serialization.Tests/Support/quantity_containers.cs new file mode 100644 index 0000000..6d2c152 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Support/quantity_containers.cs @@ -0,0 +1,12 @@ +// ReSharper disable PropertyCanBeMadeInitOnly.Global +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +namespace NMoneys.Serialization.Tests.Support; + +public class Dto +{ + + public string S { get; set; } + public MonetaryQuantity M { get; set; } +} + +public record Dtr(string S, MonetaryQuantity M); diff --git a/tests/NMoneys.Serialization.Tests/Text_Json/MoneyConverterTester.cs b/tests/NMoneys.Serialization.Tests/Text_Json/MoneyConverterTester.cs new file mode 100644 index 0000000..6ebd46b --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Text_Json/MoneyConverterTester.cs @@ -0,0 +1,405 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NMoneys.Extensions; +using NMoneys.Serialization.Tests.Support; +using NMoneys.Serialization.Text_Json; +using Testing.Commons.Serialization; + +namespace NMoneys.Serialization.Tests.Text_Json; + +[TestFixture] +public class MoneyConverterTester +{ + [Test, Category("exploratory")] + public void OutOfTheBoxSerialization_BlowsUp() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + Assert.That(() => JsonSerializer.Serialize(toSerialize), Throws.InstanceOf()); + } + + # region serialization + + [Test] + public void Serialize_DefaultPolicy_PascalCasedAndNumericCurrency() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions + { + Converters = { subject } + }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'Amount':14.3,'Currency':963}".Jsonify())); + } + + + [Test] + public void Serialize_CustomPolicyAndEnumFormat_FollowsCaseAndCurrencyFormat() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), subject } + }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'amount':14.3,'currency':'xts'}".Jsonify())); + } + + [Test] + public void Serialize_Null_DefaultNullable() + { + Money? @null = default(Money?); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + string actual = JsonSerializer.Serialize(@null, options); + Assert.That(actual, Is.EqualTo("null")); + } + + [Test] + public void Serialize_DefaultOptionsRecord_PascalCasedAndNumericCurrency() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyRecord("s", money, 42); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'S':'s','M':{'Amount':14.3,'Currency':963},'N':42}".Jsonify())); + } + + [Test] + public void Serialize_CustomContainer_FollowsConfiguration() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyContainer + { + S = "s", + M = money, + N = 42 + }; + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(), subject } + }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'s':'s','m':{'amount':14.3,'currency':'XTS'},'n':42}".Jsonify())); + } + + [Test] + public void Serialize_DefaultOptionsNullableContainer_PascalCasedAndNumericCurrency() + { + var toSerialize = new NullableMoneyContainer + { + N = 42 + }; + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'S':null,'M':null,'N':42}".Jsonify())); + } + + [Test] + public void Serialize_CustomNullableRecord_FollowsConfiguration() + { + var toSerialize = new NullableMoneyRecord(null, null, 42); + ; + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(), subject } + }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'s':null,'m':null,'n':42}".Jsonify())); + } + + #endregion + + #region deserialization + + [Test] + public void Deserialize_DefaultOptions_PascalCasedAndNumericCurrency() + { + string json = "{'Amount':14.3,'Currency':963}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + Money deserialized = JsonSerializer.Deserialize(json, options); + + Assert.That(deserialized, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_DefaultOptions_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + string json = JsonSerializer.Serialize(original, options); + var deserialized = JsonSerializer.Deserialize(json, options); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_CustomPolicyAndEnumFormat_FollowsCaseAndCurrencyFormat() + { + string json = "{'amount':14.3,'currency':'xts'}".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), subject } + }; + var actual = JsonSerializer.Deserialize(json, options); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomPolicyAndEnumFormat_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), subject } + }; + string json = JsonSerializer.Serialize(original, options); + var deserialized = JsonSerializer.Deserialize(json, options); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + + [Test] + public void Deserialize_Null_DefaultNullable() + { + var subject = new MoneyConverter(); + + var options = new JsonSerializerOptions { Converters = { subject } }; + var actual = JsonSerializer.Deserialize("null", options); + Assert.That(actual, Is.Null); + } + + [Test] + public void Deserialize_DefaultOptionsRecord_PascalCasedAndNumericCurrency() + { + string json = "{'S':'s','M':{'Amount':14.3,'Currency':963},'N':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var expected = new MoneyRecord("s", money, 42); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + MoneyRecord? actual = JsonSerializer.Deserialize(json, options); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomContainer_FollowsConfiguration() + { + string json = "{'s':'s','m':{'amount':14.3,'currency':'XTS'},'n':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(), subject } + }; + var actual = JsonSerializer.Deserialize(json, settings); + + Assert.That(actual, Has.Property("S").EqualTo("s")); + Assert.That(actual, Has.Property("M").EqualTo(money)); + Assert.That(actual, Has.Property("N").EqualTo(42)); + } + + [Test] + public void Deserialize_DefaultOptionsNullableContainer_PascalCasedAndNumericCurrency() + { + string json = "{'S':null,'M':null,'N':42}".Jsonify(); + + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + var actual = JsonSerializer.Deserialize(json, options); + + Assert.That(actual, Has.Property("S").Null); + Assert.That(actual, Has.Property("M").Null); + Assert.That(actual, Has.Property("N").EqualTo(42)); + } + + [Test] + public void Deserialize_CustomNullableRecord_FollowsConfiguration() + { + string json = "{'s':null,'m':null,'n':42}".Jsonify(); + var expected = new NullableMoneyRecord(null, null, 42); + + var subject = new MoneyConverter(); + var settings = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters ={ new JsonStringEnumConverter(), subject } + }; + var actual = JsonSerializer.Deserialize(json, settings); + + Assert.That(actual, Is.EqualTo(expected)); + } + + #region error handling + + [Test] + public void Deserialize_DefaultNotAnObject_Exception() + { + string notAJsonObject = "'str'".Jsonify(); + var subject = new MoneyConverter(); + + var options = new JsonSerializerOptions { Converters = { subject } }; + Assert.That(()=> JsonSerializer.Deserialize(notAJsonObject, options), + Throws.InstanceOf() + .With.Message.Contains("must be of type") + .And.Message.Contains("'JsonObject'")); + } + + [Test] + public void Deserialize_DefaultMissingAmount_Exception() + { + string missingAmount = "{}".Jsonify(); + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + + Assert.That(()=> JsonSerializer.Deserialize(missingAmount, options), + Throws.InstanceOf() + .With.Message.Contains("Missing property") + .And.Message.Contains("'Amount'")); + } + + [Test] + public void Deserialize_DefaultNonNumericAmount_Exception() + { + string nonNumericAmount = "{'Amount':'lol'}".Jsonify(); + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + + Assert.That(()=> JsonSerializer.Deserialize(nonNumericAmount, options), + Throws.InstanceOf() + .With.Message.Contains("cannot be converted") + .And.Message.Contains("'String'") + .And.Message.Contains("'System.Decimal'")); + } + + [Test] + public void Deserialize_CaseDoesNotMatchContract_DoesntMatter() + { + string caseMismatch = "{'Amount':1,'currency':963}".Jsonify(); + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + + Assert.That(()=> JsonSerializer.Deserialize(caseMismatch, options), + Throws.Nothing); + var deserialized = JsonSerializer.Deserialize(caseMismatch, options); + Assert.That(deserialized, Is.EqualTo(1m.Xts())); + } + + [Test] + public void Deserialize_MissingCurrency_ExceptionIgnoringCasing() + { + string missingCurrency = "{'amount':1}".Jsonify(); + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { subject } + }; + Assert.That(()=> JsonSerializer.Deserialize(missingCurrency, options), + Throws.InstanceOf() + .With.Message.Contains("Missing property") + .And.Message.Contains("'Currency'"), "no camel-casing"); + } + + [Test] + public void Deserialize_MismatchCurrencyFormat_Exception() + { + string currencyFormatMismatch = "{'amount':1,'currency':'XTS'}".Jsonify(); + var subject = new MoneyConverter(); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { subject } + }; + Assert.That(()=> JsonSerializer.Deserialize(currencyFormatMismatch, options), + Throws.InstanceOf() + .With.Message.Contains("could not be converted to") + .And.Message.Contains("NMoneys.CurrencyIsoCode")); + } + + [Test] + public void Deserialize_FunkyCurrencyValueCasing_Exception() + { + string funkyCurrencyValue = "{'amount':1,'currency':'XtS'}".Jsonify(); + var subject = new MoneyConverter(); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { subject } + }; + Assert.That(()=> JsonSerializer.Deserialize(funkyCurrencyValue, options), + Throws.InstanceOf() + .With.Message.Contains("could not be converted to") + .And.Message.Contains("NMoneys.CurrencyIsoCode")); + } + + [Test] + public void Deserialize_UndefinedCurrency_Exception() + { + string undefinedCurrency = "{'amount':1,'currency':1}".Jsonify(); + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { subject } + }; + Assert.That(()=> JsonSerializer.Deserialize(undefinedCurrency, options), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_PoorlyConstructedJson_Exception() + { + string missingObjectClose = "{'amount':1,'currency':1".Jsonify(); + var subject = new MoneyConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + + Assert.That(()=> JsonSerializer.Deserialize(missingObjectClose, options), + Throws.InstanceOf()); + } + + #endregion + + #endregion +} diff --git a/tests/NMoneys.Serialization.Tests/Text_Json/QuantityConverterTester.cs b/tests/NMoneys.Serialization.Tests/Text_Json/QuantityConverterTester.cs new file mode 100644 index 0000000..5a31f5d --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Text_Json/QuantityConverterTester.cs @@ -0,0 +1,388 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NMoneys.Extensions; +using NMoneys.Serialization.Tests.Support; +using NMoneys.Serialization.Text_Json; +using Testing.Commons.Serialization; + +namespace NMoneys.Serialization.Tests.Text_Json; + +[TestFixture] +public class QuantityConverterTester +{ + # region serialization + + [Test] + public void Serialize_DefaultPolicy_QuantityString() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions + { + Converters = { subject } + }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("'XTS 14.3'".Jsonify())); + } + + + [Test] + public void Serialize_CustomPolicyAndEnumFormat_QuantityString() + { + var toSerialize = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), subject } + }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("'XTS 14.3'".Jsonify())); + } + + [Test] + public void Serialize_Null_DefaultNullable() + { + Money? @null = default(Money?); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + string actual = JsonSerializer.Serialize(@null, options); + Assert.That(actual, Is.EqualTo("null")); + } + + [Test] + public void Serialize_DefaultOptionsRecord_QuantityString() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyRecord("s", money, 42); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'S':'s','M':'XTS 14.3','N':42}".Jsonify())); + } + + [Test] + public void Serialize_CustomContainer_FollowsConfiguration() + { + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var toSerialize = new MoneyContainer + { + S = "s", + M = money, + N = 42 + }; + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(), subject } + }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'s':'s','m':'XTS 14.3','n':42}".Jsonify())); + } + + [Test] + public void Serialize_DefaultOptionsNullableContainer_PascalCasedAndNumericCurrency() + { + var toSerialize = new NullableMoneyContainer + { + N = 42 + }; + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'S':null,'M':null,'N':42}".Jsonify())); + } + + [Test] + public void Serialize_CustomNullableRecord_FollowsConfiguration() + { + var toSerialize = new NullableMoneyRecord(null, null, 42); + ; + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(), subject } + }; + string actual = JsonSerializer.Serialize(toSerialize, options); + + Assert.That(actual, Is.EqualTo("{'s':null,'m':null,'n':42}".Jsonify())); + } + + #endregion + + #region deserialization + + [Test] + public void Deserialize_DefaultOptions_QuantityParsedProps() + { + string json = "'XTS 14.3'".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + Money deserialized = JsonSerializer.Deserialize(json, options); + + Assert.That(deserialized, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_DefaultOptions_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + string json = JsonSerializer.Serialize(original, options); + var deserialized = JsonSerializer.Deserialize(json, options); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + [Test] + public void Deserialize_CustomPolicyAndEnumFormat_QuantityParsedProps() + { + string json = "'XTS 14.3'".Jsonify(); + var expected = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), subject } + }; + var actual = JsonSerializer.Deserialize(json, options); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomPolicyAndEnumFormat_CanRoundtrip() + { + var original = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), subject } + }; + string json = JsonSerializer.Serialize(original, options); + var deserialized = JsonSerializer.Deserialize(json, options); + + Assert.That(deserialized, Is.EqualTo(original)); + } + + + [Test] + public void Deserialize_Null_DefaultNullable() + { + var subject = new QuantityConverter(); + + var options = new JsonSerializerOptions { Converters = { subject } }; + var actual = JsonSerializer.Deserialize("null", options); + Assert.That(actual, Is.Null); + } + + [Test] + public void Deserialize_DefaultOptionsRecord_PascalCasedAndNumericCurrency() + { + string json = "{'S':'s','M':'XTS 14.3','N':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + var expected = new MoneyRecord("s", money, 42); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + MoneyRecord? actual = JsonSerializer.Deserialize(json, options); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void Deserialize_CustomContainer_FollowsConfiguration() + { + string json = "{'s':'s','m':'XTS 14.3','n':42}".Jsonify(); + var money = new Money(14.3m, CurrencyIsoCode.XTS); + + var subject = new QuantityConverter(); + var settings = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(), subject } + }; + var actual = JsonSerializer.Deserialize(json, settings); + + Assert.That(actual, Has.Property("S").EqualTo("s")); + Assert.That(actual, Has.Property("M").EqualTo(money)); + Assert.That(actual, Has.Property("N").EqualTo(42)); + } + + [Test] + public void Deserialize_DefaultOptionsNullableContainer_PascalCasedAndNumericCurrency() + { + string json = "{'S':null,'M':null,'N':42}".Jsonify(); + + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + var actual = JsonSerializer.Deserialize(json, options); + + Assert.That(actual, Has.Property("S").Null); + Assert.That(actual, Has.Property("M").Null); + Assert.That(actual, Has.Property("N").EqualTo(42)); + } + + [Test] + public void Deserialize_CustomNullableRecord_FollowsConfiguration() + { + string json = "{'s':null,'m':null,'n':42}".Jsonify(); + var expected = new NullableMoneyRecord(null, null, 42); + + var subject = new QuantityConverter(); + var settings = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters ={ new JsonStringEnumConverter(), subject } + }; + var actual = JsonSerializer.Deserialize(json, settings); + + Assert.That(actual, Is.EqualTo(expected)); + } + + #region error handling + + [Test] + public void Deserialize_DefaultNotAString_Exception() + { + string notAJsonObject = "{}".Jsonify(); + var subject = new QuantityConverter(); + + var options = new JsonSerializerOptions { Converters = { subject } }; + Assert.That(()=> JsonSerializer.Deserialize(notAJsonObject, options), + Throws.InstanceOf() + .With.Message.Contains("must be of type") + .And.Message.Contains("'JsonValue'")); + } + + [Test] + public void Deserialize_DefaultMissingAmount_Exception() + { + string missingAmount = "'XTS'".Jsonify(); + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + + Assert.That(()=> JsonSerializer.Deserialize(missingAmount, options), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_DefaultNonNumericAmount_Exception() + { + string nonNumericAmount = "'XTS lol'}".Jsonify(); + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + + Assert.That(()=> JsonSerializer.Deserialize(nonNumericAmount, options), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_LowercaseCurrency_Exception() + { + string caseMismatch = "'xts 1'".Jsonify(); + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + + Assert.That(()=> JsonSerializer.Deserialize( caseMismatch, options), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_MissingCurrency_ExceptionIgnoringCasing() + { + string missingCurrency = "'1'".Jsonify(); + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { subject } + }; + Assert.That(()=> JsonSerializer.Deserialize(missingCurrency, options), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_MismatchCurrencyFormat_DoesNotMatter() + { + string currencyFormatMismatch = "'963 1'".Jsonify(); + var subject = new QuantityConverter(); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { subject } + }; + Assert.That(()=> JsonSerializer.Deserialize(currencyFormatMismatch, options), + Throws.Nothing); + + Assert.That(JsonSerializer.Deserialize(currencyFormatMismatch, options), Is.EqualTo(1m.Xts())); + } + + [Test] + public void Deserialize_FunkyCurrencyValueCasing_Exception() + { + string funkyCurrencyValue = "'XtS 1'".Jsonify(); + var subject = new QuantityConverter(); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { subject } + }; + Assert.That(()=> JsonSerializer.Deserialize(funkyCurrencyValue, options), + Throws.InstanceOf() + .With.Message.Contains("'XtS'")); + } + + [Test] + public void Deserialize_UndefinedCurrency_Exception() + { + string undefinedCurrency = "'001 1'".Jsonify(); + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { subject } + }; + Assert.That(()=> JsonSerializer.Deserialize(undefinedCurrency, options), + Throws.InstanceOf()); + } + + [Test] + public void Deserialize_PoorlyConstructedJson_Exception() + { + string missingObjectClose = "'XTS 1".Jsonify(); + var subject = new QuantityConverter(); + var options = new JsonSerializerOptions { Converters = { subject } }; + + Assert.That(()=> JsonSerializer.Deserialize(missingObjectClose, options), + Throws.InstanceOf()); + } + + #endregion + + #endregion + +} diff --git a/tests/NMoneys.Serialization.Tests/Text_Json/QuantityTester.cs b/tests/NMoneys.Serialization.Tests/Text_Json/QuantityTester.cs new file mode 100644 index 0000000..2a8c42d --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Text_Json/QuantityTester.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NMoneys.Extensions; +using NMoneys.Serialization.Tests.Support; +using Testing.Commons.Serialization; + +namespace NMoneys.Serialization.Tests.Text_Json; + +[TestFixture] +public class QuantityTester +{ + [Test] + public void CustomSerializeDto_NoNeedOfCustomSerializer() + { + var dto = new Dto { S = "str", M = new MonetaryQuantity(50m, CurrencyIsoCode.EUR) }; + + string json = JsonSerializer.Serialize(dto, new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }); + Assert.That(json, Is.EqualTo("{'s':'str','m':{'amount':50,'currency':'EUR'}}".Jsonify())); + } + + [Test] + public void CustomSerializeDtr_NoNeedOfCustomSerializer() + { + var dtr = new Dtr("str", new MonetaryQuantity(50m, CurrencyIsoCode.EUR)); + string json = JsonSerializer.Serialize(dtr, new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }); + Assert.That(json, Is.EqualTo("{'s':'str','m':{'amount':50,'currency':'EUR'}}".Jsonify())); + } + + [Test] + public void CustomDeserializeDto_NoNeedOfCustomSerializer() + { + string json = "{'s':'str','m':{'amount':50,'currency':'EUR'}}".Jsonify(); + Dto? dto = JsonSerializer.Deserialize(json, new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }); + Assert.That(dto, Has.Property("S").EqualTo("str")); + Assert.That(dto?.M, Has.Property("Amount").EqualTo(50m).And + .Property("Currency").EqualTo(CurrencyIsoCode.EUR)); + + // convert to money for operations + Money m = (Money)dto!.M; + Assert.That(m, Is.EqualTo(50m.Eur())); + } + + [Test] + public void CustomDeserializeDtr_NoNeedOfCustomSerializer() + { + string json = "{'s':'str','m':{'amount':50,'currency':'EUR'}}".Jsonify(); + Dtr? dtr = JsonSerializer.Deserialize(json, new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }); + Assert.That(dtr, Has.Property("S").EqualTo("str")); + Assert.That(dtr?.M, Has.Property("Amount").EqualTo(50m).And + .Property("Currency").EqualTo(CurrencyIsoCode.EUR)); + + // convert to money for operations + Money m = (Money)dtr!.M; + Assert.That(m, Is.EqualTo(50m.Eur())); + } +} diff --git a/tests/NMoneys.Serialization.Tests/Usings.cs b/tests/NMoneys.Serialization.Tests/Usings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/tests/NMoneys.Serialization.Tests/Usings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/tests/NMoneys.Tests/MonetaryQuantityTester.cs b/tests/NMoneys.Tests/MonetaryQuantityTester.cs new file mode 100644 index 0000000..d4e4e02 --- /dev/null +++ b/tests/NMoneys.Tests/MonetaryQuantityTester.cs @@ -0,0 +1,93 @@ +using NMoneys.Extensions; + +namespace NMoneys.Tests; + +[TestFixture] +public class MonetaryQuantityTester +{ + #region ctors + + [Test] + public void DefaultCtor_ZeroAmountAndNoCurrency() + { + var subject = new MonetaryQuantity(); + + Assert.That(subject.Amount, Is.EqualTo(decimal.Zero)); + Assert.That(subject.Currency, Is.EqualTo(CurrencyIsoCode.XXX)); + } + + [Test] + public void CompleteCtor_SetsAmountAndCurrency() + { + decimal amount = 42.375m; + + var subject = new MonetaryQuantity(amount, CurrencyIsoCode.XTS); + + Assert.That(subject.Amount, Is.EqualTo(amount)); + Assert.That(subject.Currency, Is.EqualTo(CurrencyIsoCode.XTS)); + } + + #endregion + + [Test] + public void ToString_CompactMonetaryRepresentation() + { + var subject = new MonetaryQuantity(-42.375m, CurrencyIsoCode.XTS); + + Assert.That(subject.ToString(), Is.EqualTo("XTS -42.375")); + } + + #region conversions + + #region from money + + [Test] + public void FromMoneyOperator_SetsAmountAndCurrency() + { + var subject = (MonetaryQuantity)52.75m.Xts(); + + Assert.That(subject.Amount, Is.EqualTo(52.75m)); + Assert.That(subject.Currency, Is.EqualTo(CurrencyIsoCode.XTS)); + } + + [Test] + public void FromMoney_SetsAmountAndCurrency() + { + var subject = MonetaryQuantity.FromMoney(52.75m.Xts()); + + Assert.That(subject.Amount, Is.EqualTo(52.75m)); + Assert.That(subject.Currency, Is.EqualTo(CurrencyIsoCode.XTS)); + } + + #endregion + + #region to money + + [Test] + public void ToMoneyOperator_SetsAmountAndCurrency() + { + var money = (Money)new MonetaryQuantity(52.75m, CurrencyIsoCode.XTS); + + Assert.That(money, Is.EqualTo(52.75m.Xts())); + } + + [Test] + public void StaticToMoney_SetsAmountAndCurrency() + { + var subject = new MonetaryQuantity(52.75m, CurrencyIsoCode.XTS); + + Assert.That(MonetaryQuantity.ToMoney(subject), Is.EqualTo(52.75m.Xts())); + } + + [Test] + public void InstanceToMoney_SetsAmountAndCurrency() + { + var subject = new MonetaryQuantity(52.75m, CurrencyIsoCode.XTS); + + Assert.That(subject.ToMoney(), Is.EqualTo(52.75m.Xts())); + } + + #endregion + + #endregion +} diff --git a/tests/NMoneys.Tests/MonetaryRecordTester.cs b/tests/NMoneys.Tests/MonetaryRecordTester.cs new file mode 100644 index 0000000..ec783c4 --- /dev/null +++ b/tests/NMoneys.Tests/MonetaryRecordTester.cs @@ -0,0 +1,92 @@ +using NMoneys.Extensions; + +namespace NMoneys.Tests; + +[TestFixture] +public class MonetaryRecordTester +{ + #region ctors + + [Test] + public void DefaultCtor_ZeroAmountAndNoCurrency() + { + var subject = new MonetaryRecord(); + + Assert.That(subject.Amount, Is.EqualTo(decimal.Zero)); + Assert.That(subject.Currency, Is.EqualTo(CurrencyIsoCode.XXX)); + } + + [Test] + public void CompleteCtor_SetsAmountAndCurrency() + { + decimal amount = 42.375m; + var subject = new MonetaryRecord(amount, CurrencyIsoCode.XTS); + + Assert.That(subject.Amount, Is.EqualTo(amount)); + Assert.That(subject.Currency, Is.EqualTo(CurrencyIsoCode.XTS)); + } + + #endregion + + [Test] + public void ToString_CompactMonetaryRepresentation() + { + var subject = new MonetaryRecord(-42.375m, CurrencyIsoCode.XTS); + + Assert.That(subject.ToString(), Is.EqualTo("XTS -42.375")); + } + + #region conversions + + #region from money + + [Test] + public void FromMoneyOperator_SetsAmountAndCurrency() + { + var subject = (MonetaryRecord)52.75m.Xts(); + + Assert.That(subject.Amount, Is.EqualTo(52.75m)); + Assert.That(subject.Currency, Is.EqualTo(CurrencyIsoCode.XTS)); + } + + [Test] + public void FromMoney_SetsAmountAndCurrency() + { + var subject = MonetaryRecord.FromMoney(52.75m.Xts()); + + Assert.That(subject.Amount, Is.EqualTo(52.75m)); + Assert.That(subject.Currency, Is.EqualTo(CurrencyIsoCode.XTS)); + } + + #endregion + + #region to money + + [Test] + public void ToMoneyOperator_SetsAmountAndCurrency() + { + var money = (Money)new MonetaryRecord(52.75m, CurrencyIsoCode.XTS); + + Assert.That(money, Is.EqualTo(52.75m.Xts())); + } + + [Test] + public void StaticToMoney_SetsAmountAndCurrency() + { + var subject = new MonetaryRecord(52.75m, CurrencyIsoCode.XTS); + + Assert.That(MonetaryRecord.ToMoney(subject), Is.EqualTo(52.75m.Xts())); + } + + [Test] + public void InstanceToMoney_SetsAmountAndCurrency() + { + var subject = new MonetaryRecord(52.75m, CurrencyIsoCode.XTS); + + Assert.That(subject.ToMoney(), Is.EqualTo(52.75m.Xts())); + } + + #endregion + + #endregion +} diff --git a/tests/NMoneys.Tests/MoneyTester.Formatting.cs b/tests/NMoneys.Tests/MoneyTester.Formatting.cs new file mode 100644 index 0000000..99395a4 --- /dev/null +++ b/tests/NMoneys.Tests/MoneyTester.Formatting.cs @@ -0,0 +1,34 @@ +using NMoneys.Extensions; + +namespace NMoneys.Tests; + +[TestFixture] +public partial class MoneyTester +{ + #region AsQuantity + + [Test] + public void AsQuantity_InvariantCurrency_SpaceSeparatedCapitalizedCurrencyCodeAndInvariantAmount() + { + string quantity = 13.45m.Xts().AsQuantity(); + Assert.That(quantity, Is.EqualTo("XTS 13.45")); + } + + [Test] + public void AsQuantity_NonInvariantCurrency_SpaceSeparatedCapitalizedCurrencyCodeAndInvariantAmount() + { + string quantity = (-13.45m).Eur().AsQuantity(); + Assert.That(quantity, Is.EqualTo("EUR -13.45")); + } + + [Test, TestCaseSource(nameof(quantities))] + public void AsQuantity_QuantityWrittenAsPerSpec(string expected, CurrencyIsoCode currency, decimal amount) + { + var money = new Money(amount, currency); + string actual = money.AsQuantity(); + + Assert.That(actual, Is.EqualTo(expected)); + } + + #endregion +} diff --git a/tests/NMoneys.Tests/MoneyTester.Parsing.cs b/tests/NMoneys.Tests/MoneyTester.Parsing.cs index 06799fa..5be83bd 100644 --- a/tests/NMoneys.Tests/MoneyTester.Parsing.cs +++ b/tests/NMoneys.Tests/MoneyTester.Parsing.cs @@ -9,6 +9,63 @@ public partial class MoneyTester { #region Parse + #region quantity + + private static TestCaseData[] quantities = new[] + { + new TestCaseData("XTS 50", CurrencyIsoCode.XTS, 50m) + .SetName("positive integral"), + new TestCaseData("XXX -50", CurrencyIsoCode.XXX, -50m) + .SetName("negative integral"), + new TestCaseData("GBP 5.75", CurrencyIsoCode.GBP, 5.75m) + .SetName("positive fractional"), + new TestCaseData("EUR -5.765", CurrencyIsoCode.EUR, -5.765m) + .SetName("negative fractional"), + new TestCaseData("AUD 1.000000000000000001", CurrencyIsoCode.AUD, 1.000000000000000001m) + .SetName("many decimals") + }; + + [Test, TestCaseSource(nameof(quantities))] + public void Parse_Quantity_MoneyCreatedAsPerSpec(string quantity, CurrencyIsoCode currency, decimal amount) + { + Money parsed = Money.Parse(quantity); + Assert.That(parsed, Is.EqualTo(new Money(amount, currency))); + } + + [Test] + public void Parse_ExponentialQuantity_MoneyParsed() + { + Money parsed = Money.Parse("USD 1E6"); + // cannot be written by .AsQuantity(), but can be parse + Assert.That(parsed, Is.EqualTo(1000000m.Usd())); + } + + [Test] + public void Parse_ThousandsQuantity_Exception() + { + Assert.That(() => Money.Parse("XXX 1,000.1"), Throws.InstanceOf()); + } + + [Test] + public void Parse_TrailingSignQuantity_Exception() + { + Assert.That(() => Money.Parse("USD 1-"), Throws.InstanceOf()); + } + + [Test] + public void Parse_UndefinedCurrency_Exception() + { + Assert.That(() => Money.Parse("LOL 1"), Throws.InstanceOf()); + } + + [Test] + public void Parse_MissingAmount_Exception() + { + Assert.That(() => Money.Parse("USD"), Throws.InstanceOf()); + } + + #endregion + [TestCaseSource(nameof(positiveAmounts))] public void Parse_CurrencyStyle_PositiveAmount_MoneyParsed(string positiveAmount, Currency currency, decimal amount) { @@ -134,106 +191,157 @@ public void Parse_WithStyle_IncorrectFormat_Exception() #region TryParse - [TestCaseSource(nameof(positiveAmounts))] - public void TryParse_CurrencyStyle_PositiveAmount_MoneyParsed(string positiveAmount, Currency currency, decimal amount) - { - Assert.That(Money.TryParse(positiveAmount, currency, out Money? parsed), Is.True); - Assert.That(parsed, Iz.MoneyWith(amount, currency)); - } - - [TestCaseSource(nameof(negativeAmounts))] - public void TryParse_CurrencyStyle_NegativeAmount_MoneyParsed(string negativeAmount, Currency currency, decimal amount) - { - Assert.That(Money.TryParse(negativeAmount, currency, out Money? parsed), Is.True); - Assert.That(parsed, Iz.MoneyWith(amount, currency)); - } - - [Test] - public void TryParse_CurrencyStyle_MoreSignificantDecimalDigits_PrecisionMaintained() - { - Assert.That(Money.TryParse("$1.5678", Currency.Usd, out Money? parsed), Is.True); - Assert.That(parsed, Iz.MoneyWith(1.5678m, Currency.Usd)); - } - - [Test] - public void TryParse_CurrencyStyle_CurrenciesWithNoDecimals_MaintainsAmountButNoRepresented() - { - Money.TryParse("¥1.49", Currency.Jpy, out Money? moreThanOneYen); - Assert.That(moreThanOneYen, Iz.MoneyWith(1.49m, Currency.Jpy)); - Assert.That(moreThanOneYen.ToString(), Is.EqualTo("¥1")); - - Money.TryParse("¥1.5", Currency.Jpy, out Money? lessThanTwoYen); - Assert.That(lessThanTwoYen, Iz.MoneyWith(1.5m, Currency.Jpy)); - Assert.That(lessThanTwoYen.ToString(), Is.EqualTo("¥2")); - } - - [TestCaseSource(nameof(flexibleParsing))] - public void TryParse_CurrencyStyle_IsAsFlexibleAsParsingDecimals(string flexibleParse, Currency currency, decimal amount) - { - Assert.That(Money.TryParse(flexibleParse, currency, out Money? parsed), Is.True); - Assert.That(parsed, Iz.MoneyWith(amount, currency)); - } - - [Test] - public void TryParse_Default_IncorrectFormat_False() - { - Assert.That(Money.TryParse("not a Number", Currency.None, out Money? notParsed), Is.False); - Assert.That(notParsed, Is.Null); - Assert.That(Money.TryParse("1--", Currency.None, out notParsed), Is.False); - Assert.That(notParsed, Is.Null); - } - - [Test] - public void TryParse_WithStyle_AllowsIgnoringCurrencySymbols() - { - Assert.That(Money.TryParse("3", NumberStyles.Number, Currency.Nok, out Money? parsed), Is.True); - Assert.That(parsed, Iz.MoneyWith(3m, Currency.Nok)); - } - - [Test] - public void TryParse_WithStyle_AllowsHavingStandardSignedAmounts() - { - // in usd, negative amounts go between parenthesis, we can override that behavior - Assert.That(Money.TryParse("-73", NumberStyles.Number, Currency.Usd, out Money? parsed), Is.True); - Assert.That(parsed, Iz.MoneyWith(-73m, Currency.Usd)); - } - - [Test] - public void TryParse_WithStyle_IncorrectFormat_False() - { - Assert.That(Money.TryParse("¤1.4", NumberStyles.Integer, Currency.None, out Money? notParsed), Is.False); - Assert.That(notParsed, Is.Null); - Assert.That(Money.TryParse("1e-1", NumberStyles.Currency, Currency.None, out Money? notParsedEither), Is.False); - Assert.That(notParsedEither, Is.Null); - } - - [Test, Combinatorial] - public void TryParse_CanParseAllWrittenCurrencies( - [ValueSource(nameof(nonFractionalAmounts))]decimal amount, - [ValueSource(nameof(allCurrencies))]Currency currency) - { - Money beforeWritting = new Money(amount, currency); - Money.TryParse(beforeWritting.ToString(), currency, out Money? afterParsing); - - Assert.That(beforeWritting, Is.EqualTo(afterParsing)); - } - - #region Issue 28. Support detachment of parsing logic from currency - - [Test] - public void TryParse_ParsingDetachedFromCurrency_FullControlOverParsing() - { - // currency does the parsing and the money construction: dot is not a decimal separator - Money.TryParse("€1000.00", Currency.Eur, out Money? parsed); - Assert.That(parsed, Is.EqualTo(100000m.Eur())); - - // NumberFormatInfo used for parsing, currency for building the money instance - NumberFormatInfo parser = CultureInfo.GetCultureInfo("en-GB").NumberFormat; - Money.TryParse("€1000.00", NumberStyles.Currency, parser, Currency.Eur, out parsed); - Assert.That(parsed, Is.EqualTo(1000m.Eur())); - } - - #endregion - - #endregion + #region quantity + + [Test, TestCaseSource(nameof(quantities))] + public void TryParse_Quantity_MoneyCreatedAsPerSpec(string quantity, CurrencyIsoCode currency, decimal amount) + { + Assert.That(Money.TryParse(quantity, out Money? parsed), Is.True); + Assert.That(parsed, Is.EqualTo(new Money(amount, currency))); + } + + [Test] + public void TryParse_ExponentialQuantity_MoneyParsed() + { + // cannot be written by .AsQuantity(), but can be parsed + Assert.That(Money.TryParse("USD 1E6", out Money? parsed), Is.True); + Assert.That(parsed, Is.EqualTo(1000000m.Usd())); + } + + [Test] + public void TryParse_ThousandsQuantity_False() + { + Assert.That(Money.TryParse("XXX 1,000.1", out Money? parsed), Is.False); + Assert.That(parsed, Is.Null); + } + + [Test] + public void TryParse_TrailingSignQuantity_False() + { + Assert.That(Money.TryParse("USD 1-", out Money? parsed), Is.False); + Assert.That(parsed, Is.Null); + } + + [Test] + public void TryParse_UndefinedCurrency_Exception() + { + Assert.That(Money.TryParse("LOL 1", out Money? parsed), Is.False); + Assert.That(parsed, Is.Null); + } + + [Test] + public void Parse_MissingAmount_False() + { + Assert.That(Money.TryParse("USD", out Money? parsed), Is.False); + Assert.That(parsed, Is.Null); + } + + #endregion + + [TestCaseSource(nameof(positiveAmounts))] + public void TryParse_CurrencyStyle_PositiveAmount_MoneyParsed(string positiveAmount, Currency currency, + decimal amount) + { + Assert.That(Money.TryParse(positiveAmount, currency, out Money? parsed), Is.True); + Assert.That(parsed, Iz.MoneyWith(amount, currency)); + } + + [TestCaseSource(nameof(negativeAmounts))] + public void TryParse_CurrencyStyle_NegativeAmount_MoneyParsed(string negativeAmount, Currency currency, + decimal amount) + { + Assert.That(Money.TryParse(negativeAmount, currency, out Money? parsed), Is.True); + Assert.That(parsed, Iz.MoneyWith(amount, currency)); + } + + [Test] + public void TryParse_CurrencyStyle_MoreSignificantDecimalDigits_PrecisionMaintained() + { + Assert.That(Money.TryParse("$1.5678", Currency.Usd, out Money? parsed), Is.True); + Assert.That(parsed, Iz.MoneyWith(1.5678m, Currency.Usd)); + } + + [Test] + public void TryParse_CurrencyStyle_CurrenciesWithNoDecimals_MaintainsAmountButNoRepresented() + { + Money.TryParse("¥1.49", Currency.Jpy, out Money? moreThanOneYen); + Assert.That(moreThanOneYen, Iz.MoneyWith(1.49m, Currency.Jpy)); + Assert.That(moreThanOneYen.ToString(), Is.EqualTo("¥1")); + + Money.TryParse("¥1.5", Currency.Jpy, out Money? lessThanTwoYen); + Assert.That(lessThanTwoYen, Iz.MoneyWith(1.5m, Currency.Jpy)); + Assert.That(lessThanTwoYen.ToString(), Is.EqualTo("¥2")); + } + + [TestCaseSource(nameof(flexibleParsing))] + public void TryParse_CurrencyStyle_IsAsFlexibleAsParsingDecimals(string flexibleParse, Currency currency, + decimal amount) + { + Assert.That(Money.TryParse(flexibleParse, currency, out Money? parsed), Is.True); + Assert.That(parsed, Iz.MoneyWith(amount, currency)); + } + + [Test] + public void TryParse_Default_IncorrectFormat_False() + { + Assert.That(Money.TryParse("not a Number", Currency.None, out Money? notParsed), Is.False); + Assert.That(notParsed, Is.Null); + Assert.That(Money.TryParse("1--", Currency.None, out notParsed), Is.False); + Assert.That(notParsed, Is.Null); + } + + [Test] + public void TryParse_WithStyle_AllowsIgnoringCurrencySymbols() + { + Assert.That(Money.TryParse("3", NumberStyles.Number, Currency.Nok, out Money? parsed), Is.True); + Assert.That(parsed, Iz.MoneyWith(3m, Currency.Nok)); + } + + [Test] + public void TryParse_WithStyle_AllowsHavingStandardSignedAmounts() + { + // in usd, negative amounts go between parenthesis, we can override that behavior + Assert.That(Money.TryParse("-73", NumberStyles.Number, Currency.Usd, out Money? parsed), Is.True); + Assert.That(parsed, Iz.MoneyWith(-73m, Currency.Usd)); + } + + [Test] + public void TryParse_WithStyle_IncorrectFormat_False() + { + Assert.That(Money.TryParse("¤1.4", NumberStyles.Integer, Currency.None, out Money? notParsed), Is.False); + Assert.That(notParsed, Is.Null); + Assert.That(Money.TryParse("1e-1", NumberStyles.Currency, Currency.None, out Money? notParsedEither), Is.False); + Assert.That(notParsedEither, Is.Null); + } + + [Test, Combinatorial] + public void TryParse_CanParseAllWrittenCurrencies( + [ValueSource(nameof(nonFractionalAmounts))] + decimal amount, + [ValueSource(nameof(allCurrencies))] Currency currency) + { + Money beforeWritting = new Money(amount, currency); + Money.TryParse(beforeWritting.ToString(), currency, out Money? afterParsing); + + Assert.That(beforeWritting, Is.EqualTo(afterParsing)); + } + + #region Issue 28. Support detachment of parsing logic from currency + + [Test] + public void TryParse_ParsingDetachedFromCurrency_FullControlOverParsing() + { + // currency does the parsing and the money construction: dot is not a decimal separator + Money.TryParse("€1000.00", Currency.Eur, out Money? parsed); + Assert.That(parsed, Is.EqualTo(100000m.Eur())); + + // NumberFormatInfo used for parsing, currency for building the money instance + NumberFormatInfo parser = CultureInfo.GetCultureInfo("en-GB").NumberFormat; + Money.TryParse("€1000.00", NumberStyles.Currency, parser, Currency.Eur, out parsed); + Assert.That(parsed, Is.EqualTo(1000m.Eur())); + } + + #endregion + + #endregion }