diff --git a/Common.Types.Client/ClientBootstrapper.cs b/Common.Types.Client/ClientBootstrapper.cs new file mode 100644 index 0000000..86c4a0d --- /dev/null +++ b/Common.Types.Client/ClientBootstrapper.cs @@ -0,0 +1,21 @@ +using Common.Types.TaxRates; +using Connected.Startup; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Common.Types; + +public class ClientBootstrapper : IStartup +{ + public async Task Configure(WebAssemblyHost host) + { + await Task.CompletedTask; + } + + public async Task ConfigureServices(IServiceCollection services) + { + services.Add(ServiceDescriptor.Scoped(typeof(ITaxRateService), typeof(TaxRateService))); + + await Task.CompletedTask; + } +} diff --git a/Common.Types.Client/Common.Types.Client.csproj b/Common.Types.Client/Common.Types.Client.csproj new file mode 100644 index 0000000..6f62c7f --- /dev/null +++ b/Common.Types.Client/Common.Types.Client.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + Common.Types + + + + + + + + diff --git a/Common.Types.Client/TaxRates/TaxRate.cs b/Common.Types.Client/TaxRates/TaxRate.cs new file mode 100644 index 0000000..e1c8743 --- /dev/null +++ b/Common.Types.Client/TaxRates/TaxRate.cs @@ -0,0 +1,13 @@ +using Connected.Data; + +namespace Common.Types.TaxRates; + +/// +/// This is the client implementation if the . +/// +public class TaxRate : PrimaryKey, ITaxRate +{ + public string? Name { get; init; } + public float Rate { get; init; } + public Status Status { get; init; } = Status.Enabled; +} diff --git a/Common.Types.Client/TaxRates/TaxRateService.cs b/Common.Types.Client/TaxRates/TaxRateService.cs new file mode 100644 index 0000000..194968d --- /dev/null +++ b/Common.Types.Client/TaxRates/TaxRateService.cs @@ -0,0 +1,56 @@ +using System.Collections.Immutable; +using Connected.Net; +using Connected.Remote; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Types.TaxRates; + +/// +/// This is the client implementation of the . +/// +internal class TaxRateService : EntityService, ITaxRateService, IRemoteService +{ + public TaxRateService(IHttpService http) + { + Http = http; + } + + private IHttpService Http { get; } + + + public async Task Delete(PrimaryKeyArgs e) + { + await Http.Post("http://localhost:5063/management/commonTypes/taxRates/delete", e); + } + + public async Task Insert(InsertTaxRateArgs e) + { + return await Http.Post("http://localhost:5063/management/commonTypes/taxRates/insert", e); + } + + public async Task?> Query(PrimaryKeyListArgs e) + { + return (await Http.Post?>("http://localhost:5063/management/commonTypes/taxRates/select", e)).ToImmutableList(); + } + + public async Task?> Query(QueryArgs? args) + { + return (await Http.Post?>("http://localhost:5063/management/commonTypes/taxRates/query", args ?? QueryArgs.NoPaging)).ToImmutableList(); + } + + public async Task Select(PrimaryKeyArgs e) + { + return await Http.Post("http://localhost:5063/management/commonTypes/taxRates/select", e); + } + + public async Task Select(TaxRateArgs e) + { + return await Http.Post("http://localhost:5063/management/commonTypes/taxRates/select", e); + } + + public async Task Update(UpdateTaxRateArgs e) + { + await Http.Post("http://localhost:5063/management/commonTypes/taxRates/update", e); + } +} diff --git a/Common.Types.Middleware/Common.Types.Middleware.csproj b/Common.Types.Middleware/Common.Types.Middleware.csproj new file mode 100644 index 0000000..217fffd --- /dev/null +++ b/Common.Types.Middleware/Common.Types.Middleware.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + Common.Types + + + + + + + + diff --git a/Common.Types.Middleware/Currencies/ICurrencyFormatterMiddleware.cs b/Common.Types.Middleware/Currencies/ICurrencyFormatterMiddleware.cs new file mode 100644 index 0000000..3585e6a --- /dev/null +++ b/Common.Types.Middleware/Currencies/ICurrencyFormatterMiddleware.cs @@ -0,0 +1,9 @@ +using Common.Types.Currencies; +using Connected; + +namespace Common.Types.Middleware.Currencies; + +public interface ICurrencyFormatterMiddleware : IMiddleware +{ + Task Format(TValue value, ICurrency currency, string format); +} diff --git a/Common.Types.Model/BankAccounts/BankAccountArgs.cs b/Common.Types.Model/BankAccounts/BankAccountArgs.cs new file mode 100644 index 0000000..088f938 --- /dev/null +++ b/Common.Types.Model/BankAccounts/BankAccountArgs.cs @@ -0,0 +1,32 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Types.BankAccounts; + +public class BankAccountInsertArgs : Dto +{ + [MinValue(1)] + public int Bank { get; set; } + + [Required] + [MaxLength(128)] + public string? Iban { get; set; } + + [Required] + [MaxLength(128)] + public string? Type { get; set; } + + [Required] + [MaxLength(128)] + public string? PrimaryKey { get; set; } + + public Status Status { get; set; } = Status.Enabled; +} + +public sealed class BankAccountUpdateArgs : BankAccountInsertArgs +{ + [MinValue(1)] + public int Id { get; set; } +} diff --git a/Common.Types.Model/BankAccounts/IBankAccount.cs b/Common.Types.Model/BankAccounts/IBankAccount.cs new file mode 100644 index 0000000..4e3029d --- /dev/null +++ b/Common.Types.Model/BankAccounts/IBankAccount.cs @@ -0,0 +1,12 @@ +using Connected.Data; + +namespace Common.Types.BankAccounts; + +public interface IBankAccount : IPrimaryKey +{ + string Iban { get; init; } + Status Status { get; init; } + int Bank { get; init; } + string Type { get; init; } + string PrimaryKey { get; init; } +} diff --git a/Common.Types.Model/BankAccounts/IBankAccountService.cs b/Common.Types.Model/BankAccounts/IBankAccountService.cs new file mode 100644 index 0000000..287e529 --- /dev/null +++ b/Common.Types.Model/BankAccounts/IBankAccountService.cs @@ -0,0 +1,32 @@ +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using System.Collections.Immutable; + +namespace Common.Types.BankAccounts; + +[Service] +[ServiceUrl(Routes.BankAccounts)] +public interface IBankAccountService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get)] + Task?> Query(); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Delete)] + Task Delete(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(BankAccountInsertArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put | ServiceMethodVerbs.Patch)] + Task Update(BankAccountUpdateArgs args); +} diff --git a/Common.Types.Model/Banks/BankArgs.cs b/Common.Types.Model/Banks/BankArgs.cs new file mode 100644 index 0000000..0e178b8 --- /dev/null +++ b/Common.Types.Model/Banks/BankArgs.cs @@ -0,0 +1,28 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Types.Banks; + +public class InsertBankArgs : Dto +{ + [Required] + [MaxLength(128)] + public string? Name { get; set; } + + [MinValue(1)] + public int Country { get; set; } + + [Required] + [MaxLength(128)] + public string? Bic { get; set; } + + public Status Status { get; set; } = Status.Enabled; +} + +public class UpdateBankArgs : InsertBankArgs +{ + [MinValue(1)] + public int Id { get; set; } +} \ No newline at end of file diff --git a/Common.Types.Model/Banks/IBank.cs b/Common.Types.Model/Banks/IBank.cs new file mode 100644 index 0000000..02e4484 --- /dev/null +++ b/Common.Types.Model/Banks/IBank.cs @@ -0,0 +1,11 @@ +using Connected.Data; + +namespace Common.Types.Banks; + +public interface IBank : IPrimaryKey +{ + string Name { get; init; } + int Country { get; init; } + string Bic { get; init; } + Status Status { get; init; } +} diff --git a/Common.Types.Model/Banks/IBankService.cs b/Common.Types.Model/Banks/IBankService.cs new file mode 100644 index 0000000..b9ee88c --- /dev/null +++ b/Common.Types.Model/Banks/IBankService.cs @@ -0,0 +1,29 @@ +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using System.Collections.Immutable; + +namespace Common.Types.Banks; + +[Service] +[ServiceUrl(Routes.Banks)] +public interface IBankService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get)] + Task?> Query(); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(InsertBankArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put | ServiceMethodVerbs.Patch)] + Task Update(UpdateBankArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Delete)] + Task Delete(PrimaryKeyArgs args); +} diff --git a/Common.Types.Model/Common - Backup.Types.Model.csproj b/Common.Types.Model/Common - Backup.Types.Model.csproj new file mode 100644 index 0000000..bb4fcea --- /dev/null +++ b/Common.Types.Model/Common - Backup.Types.Model.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + $(MSBuildProjectName.Replace(" ", "_")) + + + + + + + + diff --git a/Common.Types.Model/Common.Types.Model.csproj b/Common.Types.Model/Common.Types.Model.csproj new file mode 100644 index 0000000..5c4b483 --- /dev/null +++ b/Common.Types.Model/Common.Types.Model.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + Common.Types + + + + + + + + diff --git a/Common.Types.Model/Continent/ContinentArgs.cs b/Common.Types.Model/Continent/ContinentArgs.cs new file mode 100644 index 0000000..ed6a324 --- /dev/null +++ b/Common.Types.Model/Continent/ContinentArgs.cs @@ -0,0 +1,18 @@ +using Connected.Annotations; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Types.Continent; + +public class InsertContinentArgs : Dto +{ + [Required] + [MaxLength(128)] + public string? Name { get; set; } +} + +public sealed class UpdateContitentArgs : InsertContinentArgs +{ + [MinValue(1)] + public int Id { get; set; } +} diff --git a/Common.Types.Model/Continent/Countries/ContinentCountryArgs.cs b/Common.Types.Model/Continent/Countries/ContinentCountryArgs.cs new file mode 100644 index 0000000..f8174c0 --- /dev/null +++ b/Common.Types.Model/Continent/Countries/ContinentCountryArgs.cs @@ -0,0 +1,13 @@ +using Connected.Annotations; +using Connected.ServiceModel; + +namespace Common.Types.Continent.Countries; + +public class InsertContinentCountryArgs : Dto +{ + [MinValue(1)] + public int Continent { get; set; } + + [MinValue(1)] + public int Country { get; set; } +} diff --git a/Common.Types.Model/Continent/Countries/IContinentCountry.cs b/Common.Types.Model/Continent/Countries/IContinentCountry.cs new file mode 100644 index 0000000..a1eb464 --- /dev/null +++ b/Common.Types.Model/Continent/Countries/IContinentCountry.cs @@ -0,0 +1,9 @@ +using Connected.Data; + +namespace Common.Types.Continent.Countries; + +public interface IContinentCountry : IPrimaryKey +{ + int Continent { get; init; } + int Country { get; init; } +} diff --git a/Common.Types.Model/Continent/Countries/IContinentCountryService.cs b/Common.Types.Model/Continent/Countries/IContinentCountryService.cs new file mode 100644 index 0000000..6de38bf --- /dev/null +++ b/Common.Types.Model/Continent/Countries/IContinentCountryService.cs @@ -0,0 +1,34 @@ +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using System.Collections.Immutable; + +namespace Common.Types.Continent.Countries; + +[Service] +[ServiceUrl(Routes.ContinentCountries)] +public interface IContinentCountryService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get)] + Task?> Query(); + /// + /// Queries countries for the specified continent id. + /// + /// The id fo the continent. + /// The list of countries. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> QueryCountries(PrimaryKeyArgs args); + /// + /// Select a entity for the specified country id. + /// + /// The country id. + /// The entity if exists, null otherwise. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task SelectCountry(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(InsertContinentCountryArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Delete)] + Task Delete(PrimaryKeyArgs args); +} diff --git a/Common.Types.Model/Continent/IContinent.cs b/Common.Types.Model/Continent/IContinent.cs new file mode 100644 index 0000000..b6c389c --- /dev/null +++ b/Common.Types.Model/Continent/IContinent.cs @@ -0,0 +1,8 @@ +using Connected.Data; + +namespace Common.Types.Continent; + +public interface IContinent : IPrimaryKey +{ + string Name { get; init; } +} diff --git a/Common.Types.Model/Continent/IContinentService.cs b/Common.Types.Model/Continent/IContinentService.cs new file mode 100644 index 0000000..e0de3f2 --- /dev/null +++ b/Common.Types.Model/Continent/IContinentService.cs @@ -0,0 +1,29 @@ +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using System.Collections.Immutable; + +namespace Common.Types.Continent; + +[Service] +[ServiceUrl(Routes.Continents)] +public interface IContinentService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get)] + Task?> Query(); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Put | ServiceMethodVerbs.Post)] + Task Insert(InsertContinentArgs args); + + [ServiceMethod(ServiceMethodVerbs.Put | ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch)] + Task Update(UpdateContitentArgs args); + + [ServiceMethod(ServiceMethodVerbs.Delete | ServiceMethodVerbs.Post)] + Task Delete(PrimaryKeyArgs args); +} diff --git a/Common.Types.Model/Countries/CountryArgs.cs b/Common.Types.Model/Countries/CountryArgs.cs new file mode 100644 index 0000000..51c1a61 --- /dev/null +++ b/Common.Types.Model/Countries/CountryArgs.cs @@ -0,0 +1,27 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Types.Countries; + +public class InsertCountryArgs : Dto +{ + [Required] + [MaxLength(128)] + public string Name { get; set; } + + [MinValue(0)] + public int Lcid { get; set; } + + [MaxLength(128)] + public string? IsoCode { get; set; } + + public Status Status { get; set; } = Status.Enabled; +} + +public class UpdateCountryArgs : InsertCountryArgs +{ + [MinValue(1)] + public int Id { get; set; } +} diff --git a/Common.Types.Model/Countries/ICountry.cs b/Common.Types.Model/Countries/ICountry.cs new file mode 100644 index 0000000..60b906a --- /dev/null +++ b/Common.Types.Model/Countries/ICountry.cs @@ -0,0 +1,11 @@ +using Connected.Data; + +namespace Common.Types.Countries; + +public interface ICountry : IPrimaryKey +{ + string Name { get; init; } + int Lcid { get; init; } + string IsoCode { get; init; } + Status Status { get; init; } +} diff --git a/Common.Types.Model/Countries/ICountryService.cs b/Common.Types.Model/Countries/ICountryService.cs new file mode 100644 index 0000000..2215333 --- /dev/null +++ b/Common.Types.Model/Countries/ICountryService.cs @@ -0,0 +1,33 @@ +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using System.Collections.Immutable; + +namespace Common.Types.Countries; + +[Service] +[ServiceUrl(Routes.Countries)] + +public interface ICountryService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get)] + Task?> Query(); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(NameArgs args); + + [ServiceMethod(ServiceMethodVerbs.Delete | ServiceMethodVerbs.Post)] + Task Delete(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Put | ServiceMethodVerbs.Post)] + Task Insert(InsertCountryArgs args); + + [ServiceMethod(ServiceMethodVerbs.Put | ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch)] + Task Update(UpdateCountryArgs args); +} diff --git a/Common.Types.Model/Currencies/CurrencyArgs.cs b/Common.Types.Model/Currencies/CurrencyArgs.cs new file mode 100644 index 0000000..e05aeae --- /dev/null +++ b/Common.Types.Model/Currencies/CurrencyArgs.cs @@ -0,0 +1,40 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Types.Currencies; + +public class CurrencyInsertArgs : Dto +{ + [Required] + [MaxLength(128)] + public string? Name { get; set; } + + [Required] + [MaxLength(32)] + public string? Code { get; set; } + + [Required] + [MaxLength(8)] + public string? Symbol { get; set; } + + [MinValue(0)] + public int Lcid { get; set; } + + public Status Status { get; set; } = Status.Enabled; +} + +public sealed class CurrencyUpdateArgs : CurrencyInsertArgs +{ + [MinValue(1)] + public int Id { get; set; } +} + +public sealed class CurrencyFormatArgs : Dto +{ + [Range(0, int.MaxValue)] + public int Currency { get; set; } + + public double Value { get; set; } +} diff --git a/Common.Types.Model/Currencies/ICurrency.cs b/Common.Types.Model/Currencies/ICurrency.cs new file mode 100644 index 0000000..972eeae --- /dev/null +++ b/Common.Types.Model/Currencies/ICurrency.cs @@ -0,0 +1,12 @@ +using Connected.Data; + +namespace Common.Types.Currencies; + +public interface ICurrency : IPrimaryKey +{ + string Name { get; init; } + string Code { get; init; } + string Symbol { get; init; } + int Lcid { get; init; } + Status Status { get; init; } +} diff --git a/Common.Types.Model/Currencies/ICurrencyService.cs b/Common.Types.Model/Currencies/ICurrencyService.cs new file mode 100644 index 0000000..e6081e8 --- /dev/null +++ b/Common.Types.Model/Currencies/ICurrencyService.cs @@ -0,0 +1,32 @@ +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using System.Collections.Immutable; + +namespace Common.Types.Currencies; + +[Service] +[ServiceUrl(Routes.Currencies)] +public interface ICurrencyService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get)] + Task> Query(); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Format(CurrencyFormatArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(CurrencyInsertArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Patch | ServiceMethodVerbs.Put)] + Task Update(CurrencyUpdateArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Delete)] + Task Delete(PrimaryKeyArgs args); +} diff --git a/Common.Types.Model/MeasureUnits/IMeasureUnit.cs b/Common.Types.Model/MeasureUnits/IMeasureUnit.cs new file mode 100644 index 0000000..7cdfb22 --- /dev/null +++ b/Common.Types.Model/MeasureUnits/IMeasureUnit.cs @@ -0,0 +1,11 @@ +using Connected.Data; + +namespace Common.Types.MeasureUnits; + +public interface IMeasureUnit : IPrimaryKey +{ + string Name { get; init; } + string Code { get; init; } + byte Precision { get; init; } + Status Status { get; init; } +} diff --git a/Common.Types.Model/MeasureUnits/IMeasureUnitService.cs b/Common.Types.Model/MeasureUnits/IMeasureUnitService.cs new file mode 100644 index 0000000..8bf2880 --- /dev/null +++ b/Common.Types.Model/MeasureUnits/IMeasureUnitService.cs @@ -0,0 +1,29 @@ +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using System.Collections.Immutable; + +namespace Common.Types.MeasureUnits; + +[Service] +[ServiceUrl(Routes.MeasureUnits)] +public interface IMeasureUnitService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get)] + Task?> Query(); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Delete)] + Task Delete(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(MeasureUnitInsertArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put | ServiceMethodVerbs.Patch)] + Task Update(MeasureUnitUpdateArgs args); +} diff --git a/Common.Types.Model/MeasureUnits/MeasureUnitArgs.cs b/Common.Types.Model/MeasureUnits/MeasureUnitArgs.cs new file mode 100644 index 0000000..f54131e --- /dev/null +++ b/Common.Types.Model/MeasureUnits/MeasureUnitArgs.cs @@ -0,0 +1,27 @@ +using Connected.Data; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Types.MeasureUnits; + +public class MeasureUnitInsertArgs : Dto +{ + [Required] + [MaxLength(128)] + public string? Name { get; set; } + + [Required] + [MaxLength(8)] + public string? Code { get; set; } + + [Range(0, 32)] + public byte Precision { get; set; } + + public Status Status { get; set; } = Status.Enabled; +} + +public sealed class MeasureUnitUpdateArgs : MeasureUnitInsertArgs +{ + [Range(1, int.MaxValue)] + public int Id { get; set; } +} diff --git a/Common.Types.Model/MicroService.cs b/Common.Types.Model/MicroService.cs new file mode 100644 index 0000000..c8460b9 --- /dev/null +++ b/Common.Types.Model/MicroService.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +using Connected.Annotations; + +[assembly: MicroService(MicroServiceType.Contract)] +[assembly: InternalsVisibleTo("Common.Types.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] \ No newline at end of file diff --git a/Common.Types.Model/PostalCodes/IPostalCode.cs b/Common.Types.Model/PostalCodes/IPostalCode.cs new file mode 100644 index 0000000..a77b892 --- /dev/null +++ b/Common.Types.Model/PostalCodes/IPostalCode.cs @@ -0,0 +1,11 @@ +using Connected.Data; + +namespace Common.Types.PostalCodes; + +public interface IPostalCode : IPrimaryKey +{ + int Country { get; init; } + string Name { get; init; } + string Code { get; init; } + Status Status { get; init; } +} diff --git a/Common.Types.Model/PostalCodes/IPostalCodeService.cs b/Common.Types.Model/PostalCodes/IPostalCodeService.cs new file mode 100644 index 0000000..c768539 --- /dev/null +++ b/Common.Types.Model/PostalCodes/IPostalCodeService.cs @@ -0,0 +1,35 @@ +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; +using System.Collections.Immutable; + +namespace Common.Types.PostalCodes; + +[Service] +[ServiceUrl(Routes.PostalCodes)] +public interface IPostalCodeService : IServiceNotifications +{ + [ServiceMethod(ServiceMethodVerbs.Get)] + Task?> Query(); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(PrimaryKeyListArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Search(PostalCodeSearchArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Delete)] + Task Delete(PrimaryKeyArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put)] + Task Insert(PostalCodeInsertArgs args); + + [ServiceMethod(ServiceMethodVerbs.Post | ServiceMethodVerbs.Put | ServiceMethodVerbs.Patch)] + Task Update(PostalCodeInsertArgs args); +} diff --git a/Common.Types.Model/PostalCodes/PostalCodeArgs.cs b/Common.Types.Model/PostalCodes/PostalCodeArgs.cs new file mode 100644 index 0000000..f12d5f1 --- /dev/null +++ b/Common.Types.Model/PostalCodes/PostalCodeArgs.cs @@ -0,0 +1,40 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Types.PostalCodes; + +public class PostalCodeInsertArgs : Dto +{ + [MinValue(1)] + public int Country { get; set; } + + [Required] + [MaxLength(128)] + public string? Name { get; set; } + + [Required] + [MaxLength(16)] + public string? Code { get; set; } + + public Status Status { get; set; } = Status.Enabled; +} + +public sealed class PostalCodeUpdateArgs : PostalCodeInsertArgs +{ + [Range(1, int.MaxValue)] + public int Id { get; set; } +} + +public sealed class PostalCodeSearchArgs : Dto +{ + [Range(0, int.MaxValue)] + public int Country { get; set; } + + [MaxLength(128)] + public string? Name { get; set; } + + [MaxLength(16)] + public string? Code { get; set; } +} diff --git a/Common.Types.Model/Routes.cs b/Common.Types.Model/Routes.cs new file mode 100644 index 0000000..92b7922 --- /dev/null +++ b/Common.Types.Model/Routes.cs @@ -0,0 +1,19 @@ +using C = Common.CommonRoutes; + +namespace Common.Types +{ + public static class Routes + { + public const string CommonTypes = "commonTypes"; + + public const string TaxRates = $"{C.Management}/{CommonTypes}/taxRates"; + public const string Countries = $"{C.Management}/{CommonTypes}/countries"; + public const string Continents = $"{C.Management}/{CommonTypes}/continents"; + public const string ContinentCountries = $"{C.Management}/{CommonTypes}/continents/countries"; + public const string Banks = $"{C.Management}/{CommonTypes}/banks"; + public const string BankAccounts = $"{C.Management}/{CommonTypes}/bankAccounts"; + public const string Currencies = $"{C.Management}/{CommonTypes}/currencies"; + public const string PostalCodes = $"{C.Management}/{CommonTypes}/postalCodes"; + public const string MeasureUnits = $"{C.Management}/{CommonTypes}/measureUnits"; + } +} diff --git a/Common.Types.Model/TaxRates/ITaxRate.cs b/Common.Types.Model/TaxRates/ITaxRate.cs new file mode 100644 index 0000000..c38ad31 --- /dev/null +++ b/Common.Types.Model/TaxRates/ITaxRate.cs @@ -0,0 +1,36 @@ +using Connected.Data; + +namespace Common.Types.TaxRates; + +/// +/// Represents an entity which contains information about tax rate. +/// +/// +/// Every Tax system has one or more tax rates, for example one tax rate is for +/// products and the other is for servies. This entity enables the environment +/// to manage different tax rates. +/// +public interface ITaxRate : IPrimaryKey +{ + /// + /// The name of the tax rate. + /// + /// + /// Define this value as descriptive as possible so users can + /// quickly recognize whyt kind of tax rate defines each record. + /// + string? Name { get; init; } + /// + /// The rate or value of the tax rate. Should be a positive number. + /// + /// + /// Avoid updating this value. Create a new entity instead + /// and disable the previous entity by setting its value to . + /// + float Rate { get; init; } + /// + /// The status of tax rate. Tax rates that are not used in the environment should be marked + /// as so users can use only valid records. + /// + Status Status { get; init; } +} \ No newline at end of file diff --git a/Common.Types.Model/TaxRates/ITaxRateService.cs b/Common.Types.Model/TaxRates/ITaxRateService.cs new file mode 100644 index 0000000..42f3903 --- /dev/null +++ b/Common.Types.Model/TaxRates/ITaxRateService.cs @@ -0,0 +1,63 @@ +using System.Collections.Immutable; +using Connected.Annotations; +using Connected.Notifications; +using Connected.ServiceModel; + +namespace Common.Types.TaxRates; + +/// +/// The service used to manipulate with the entity. +/// +[Service] +[ServiceUrl(Routes.TaxRates)] +public interface ITaxRateService : IServiceNotifications +{ + /// + /// Queries all valid entities. + /// + /// An representing all valid entities. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(QueryArgs? args); + /// + /// Performs a lookup on entities for the specified list of ids. + /// + /// The List of the ids for which the perform a query. + /// An of entities that matches + /// the passed ids. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task?> Query(PrimaryKeyListArgs e); + /// + /// Selects an entity for the specified id. + /// + /// The which contains id. + /// First that matches the arguments. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs e); + /// + /// Selects an for the specified arguments. + /// + /// The arguments for which the entity + /// will be returned. + /// First that matches the arguments. + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(TaxRateArgs e); + /// + /// Inserts a new entity. + /// + /// + /// An Id of the newly inserted entity. + /// + [ServiceMethod(ServiceMethodVerbs.Post)] + Task Insert(InsertTaxRateArgs e); + /// + /// Updates entity. + /// + /// The containing values to be updated. + [ServiceMethod(ServiceMethodVerbs.Post)] + Task Update(UpdateTaxRateArgs e); + /// + /// Deletes entity. + /// + [ServiceMethod(ServiceMethodVerbs.Delete | ServiceMethodVerbs.Post)] + Task Delete(PrimaryKeyArgs e); +} diff --git a/Common.Types.Model/TaxRates/TaxRateArgs.cs b/Common.Types.Model/TaxRates/TaxRateArgs.cs new file mode 100644 index 0000000..8c18f39 --- /dev/null +++ b/Common.Types.Model/TaxRates/TaxRateArgs.cs @@ -0,0 +1,45 @@ +using Connected.Annotations; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Types.TaxRates; + +/// +/// The arguments class for methods. +/// +public class TaxRateArgs : Dto +{ + /// + /// The name. + /// + /// + /// This is a required property with its max length of 128 characters. + /// + [Required] + [MaxLength(128)] + public string? Name { get; set; } + /// + /// The rate. + /// + /// + /// This is a required property with it min value of 0. + /// + [MinValue(0)] + public float Rate { get; set; } +} +/// +/// The arguments used when inserting a new entity. +/// This is only a markup class serving primary for validation distinction. +/// +public sealed class InsertTaxRateArgs : TaxRateArgs +{ + +} +/// +/// The arguments class used when updating entity. +/// +public class UpdateTaxRateArgs : TaxRateArgs +{ + [MinValue(0)] + public int Id { get; set; } +} diff --git a/Common.Types.Tests/Common.Types.Tests.csproj b/Common.Types.Tests/Common.Types.Tests.csproj new file mode 100644 index 0000000..5a21ae0 --- /dev/null +++ b/Common.Types.Tests/Common.Types.Tests.csproj @@ -0,0 +1,25 @@ + + + + net7.0 + enable + enable + + false + + Common.Types + + + + + + + + + + + + + + + diff --git a/Common.Types.Tests/InstanceFaker.cs b/Common.Types.Tests/InstanceFaker.cs new file mode 100644 index 0000000..7b8f8b6 --- /dev/null +++ b/Common.Types.Tests/InstanceFaker.cs @@ -0,0 +1,61 @@ +using Moq; + +namespace Common.Types; + +internal class InstanceFaker +{ + private readonly Dictionary _mocks = new(); + private bool _mocksGenerated = false; + + public InstanceFaker() + { + if (typeof(T).GetConstructors().Length > 1) + throw new Exception($"Multiple constructors for type {typeof(T).FullName} found. Auto resolve not possible."); + + GenerateMocks(); + } + + private void GenerateMocks() + { + if (_mocksGenerated) + return; + + _mocksGenerated = true; + + var constructor = typeof(T).GetConstructors()[0]; + + var parameters = constructor.GetParameters(); + + foreach (var parameter in parameters) + { + if (!parameter.ParameterType.IsInterface) + throw new Exception($"Constructor parameter {parameter.Name} of type {parameter.ParameterType.FullName} is not an interface, cannot mock."); + + _mocks.Add(parameter.ParameterType, CreateMock(parameter.ParameterType)); + } + } + + private T ProcessType() + { + GenerateMocks(); + + var constructor = typeof(T).GetConstructors()[0]; + + var mocks = _mocks.Select(e => e.Value.Object); + + return (T)constructor.Invoke(mocks.Cast().ToArray()); + } + + private Mock? CreateMock(Type type) + { + var creator = typeof(Mock<>).MakeGenericType(type); + return Activator.CreateInstance(creator) as Mock; + } + + public Mock? GetMock() + where T : class => _mocks.GetValueOrDefault(typeof(T)) as Mock; + + private T _instance; + + public T Instance => _instance ??= ProcessType(); +} diff --git a/Common.Types.Tests/TaxRates/Ops/DeleteTests.cs b/Common.Types.Tests/TaxRates/Ops/DeleteTests.cs new file mode 100644 index 0000000..38b4e32 --- /dev/null +++ b/Common.Types.Tests/TaxRates/Ops/DeleteTests.cs @@ -0,0 +1,189 @@ +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.ServiceModel.Transactions; + +using Moq; +using static Common.Types.TaxRates.TaxRateOps; + +namespace Common.Types.TaxRates.Ops; + +public class DeleteTests +{ + [TestClass] + public class OnInvoke + { + [TestMethod("Delete invoke calls update with state as deleted")] + public async Task Delete_Invoke_CallsUpdateWithStateDeleted() + { + /* + * Setup + */ + var expectedValue = State.Deleted; + var actualValue = (State)0; + /* + * Required services + */ + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + _ = databaseContextProviderFake + .Setup(e => e.Open().Update(It.IsAny())) + .Callback(e => actualValue = e.State) + .ReturnsAsync(new TaxRate { Id = 1 }); + /* + * Arguments play no role for this test + */ + var args = new PrimaryKeyArgs(1); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await serviceFunction.Invoke(args); + /* + * Assert + */ + databaseContextProviderFake.Verify(e => e.Open().Update(It.IsAny()), Times.Exactly(1)); + Assert.AreEqual(expectedValue, actualValue); + } + + [TestMethod("Delete invoke correctly maps values to entity")] + public async Task Delete_Invoke_MapsArgumentsCorrectly() + { + /* + * Setup + */ + var expectedId = 1; + var actualValue = int.MinValue; + /* + * Required services + */ + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + _ = databaseContextProviderFake + .Setup(e => e.Open().Update(It.IsAny())) + .Callback(e => actualValue = e.Id) + .ReturnsAsync(new TaxRate { Id = expectedId }); + /* + * Arguments play no role for this test + */ + var args = new PrimaryKeyArgs(expectedId); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await serviceFunction.Invoke(args); + /* + * Assert + */ + databaseContextProviderFake.Verify(e => e.Open().Update(It.IsAny()), Times.Exactly(1)); + Assert.AreEqual(expectedId, actualValue); + } + } + + [TestClass] + public class OnCommit + { + [TestMethod("Delete commit invokes deleted event exactly once")] + public async Task Delete_Commit_InvokedDeleted_ExactlyOnce() + { + /* + * Setup + */ + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake.Setup(e => e.Refresh(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake.SetupEvent(ServiceEvents.Deleted); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + + serviceFunction.SetArguments(new PrimaryKeyArgs(1)); + + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + eventServiceFake.VerifyEvent(ServiceEvents.Deleted); + } + + [TestMethod("Delete commit removes cache exactly once")] + public async Task Delete_Commit_RemovesCache_ExactlyOnce() + { + /* + * Setup + */ + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake.Setup(e => e.Remove(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake.SetupEvent(ServiceEvents.Deleted); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + + serviceFunction.SetArguments(new PrimaryKeyArgs(1)); + + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + taxRateCacheFake.Verify(e => e.Remove(It.IsAny()), Times.Exactly(1)); + } + + [TestMethod("Delete commit fires deleted event after notifying cache")] + public async Task Delete_Commit_InvokedDeletedEvent_AfterNotifyingCache() + { + /* + * Setup + * + * If you receive an error about function calls not properly set up, and function + * calls look ok, it's probably the sequence throwing the exception. This is a + * failure in user code, not the test. + */ + var sequence = new MockSequence(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake + .InSequence(sequence) + .Setup(e => e.Remove(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake + .InSequence(sequence) + .SetupEvent(ServiceEvents.Deleted); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + + serviceFunction.SetArguments(new PrimaryKeyArgs(1)); + + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + taxRateCacheFake.Verify(e => e.Remove(It.IsAny()), Times.Exactly(1)); + eventServiceFake.VerifyEvent(ServiceEvents.Deleted); + } + } +} \ No newline at end of file diff --git a/Common.Types.Tests/TaxRates/Ops/InsertTests.cs b/Common.Types.Tests/TaxRates/Ops/InsertTests.cs new file mode 100644 index 0000000..a1f5a2a --- /dev/null +++ b/Common.Types.Tests/TaxRates/Ops/InsertTests.cs @@ -0,0 +1,269 @@ +using Connected.Data; +using Connected.Entities; +using Connected.Entities.Annotations; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.ServiceModel.Transactions; +using Moq; +using System.Reflection; +using static Common.Types.TaxRates.TaxRateOps; + +namespace Common.Types.TaxRates.Ops; + +public class InsertTests +{ + [TestClass] + public class OnInvoke + { + [TestMethod("Insert invoke returns inserted id")] + public async Task Insert_Invoke_ReturnsInsertedId() + { + /* + * Setup + */ + var expectedId = 1; + /* + * Required services + */ + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + _ = databaseContextProviderFake + .Setup(e => e.Open().Update(It.IsAny())) + .ReturnsAsync(new TaxRate { Id = expectedId }); + /* + * Arguments play no role for this test + */ + var args = new InsertTaxRateArgs { }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.AreEqual(result, expectedId); + databaseContextProviderFake.Verify(e => e.Open().Update(It.IsAny()), Times.Exactly(1)); + } + + [TestMethod("Insert invoke inserts exactly one new value")] + public async Task Insert_Invoke_InsertsNewValue_ExactlyOnce() + { + /* + * Setup + */ + var expectedId = 1; + /* + * Required services + */ + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + _ = databaseContextProviderFake + .Setup(e => e.Open().Update(It.Is(e => e.State == State.New))) + .ReturnsAsync(new TaxRate { Id = expectedId }); + /* + * Arguments play no role for this test + */ + var args = new InsertTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + databaseContextProviderFake.Verify(e => e.Open().Update(It.IsAny()), Times.Exactly(1)); + } + + [TestMethod("Insert invoke correctly maps values to entity")] + public async Task Insert_Invoke_MapsArgumentsCorrectly() + { + /* + * Setup + */ + var expectedEntity = new TaxRate + { + Name = "TaxRate", + Rate = 1.0f, + }; + /* + * Required services + */ + var actualEntity = null as TaxRate; + + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + _ = databaseContextProviderFake + .Setup(e => e.Open().Update(It.IsAny())) + .Callback(e => actualEntity = e) + .ReturnsAsync(expectedEntity with { Id = 1 }); + + var args = new InsertTaxRateArgs + { + Name = expectedEntity.Name, + Rate = expectedEntity.Rate + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + databaseContextProviderFake.Verify(e => e.Open().Update(It.IsAny()), Times.Exactly(1)); + Assert.AreEqual(expectedEntity.Name, actualEntity?.Name); + Assert.AreEqual(expectedEntity.Rate, actualEntity?.Rate); + } + + [TestMethod("Insert invoke sets status to enabled or default")] + public async Task Insert_Invoke_SetsStatusToEnabledOrDefault() + { + /* + * Setup + * Valid values are Enabled or Default (db provider sets it to enabled then) + */ + var expectedStatuses = new[] { Status.Enabled }.ToList(); + /* + * Check if the default value for the status field is enabled. If so, add 0 to the expected values list. + */ + var defaultValueAttributes = typeof(TaxRate).GetProperty(nameof(TaxRate.Status))!.GetCustomAttributes(true); + + if (defaultValueAttributes.Any()) + { + var values = defaultValueAttributes.Select(e => (Status)e.Value!).ToList(); + + if (values.Any(e => e == Status.Enabled)) + expectedStatuses.Add(0); + } + /* + * Required services + */ + var actualStatus = (Status)0; + + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + _ = databaseContextProviderFake + .Setup(e => e.Open().Update(It.IsAny())) + .Callback(e => actualStatus = e.Status) + .ReturnsAsync(new TaxRate { Id = 1 }); + /* + * Arguments play no role for this test + */ + var args = new InsertTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + databaseContextProviderFake.Verify(e => e.Open().Update(It.IsAny()), Times.Exactly(1)); + Assert.IsTrue(expectedStatuses.Contains(actualStatus)); + } + } + + [TestClass] + public class OnCommit + { + [TestMethod("Insert commit invokes inserted event exactly once")] + public async Task Insert_Commit_InvokedInserted_ExactlyOnce() + { + /* + * Setup + */ + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake.Setup(e => e.Refresh(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake.Setup(e => e.Enqueue(It.IsAny(), ServiceEvents.Inserted, It.IsAny())); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + eventServiceFake.Verify(e => e.Enqueue(It.IsAny(), ServiceEvents.Inserted, It.IsAny()), Times.Exactly(1)); + } + + [TestMethod("Insert commit refreshes cache exactly once")] + public async Task Insert_Commit_RefreshesCache_ExactlyOnce() + { + /* + * Setup + */ + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake.Setup(e => e.Refresh(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake.Setup(e => e.Enqueue(It.IsAny(), ServiceEvents.Inserted, It.IsAny())); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + taxRateCacheFake.Verify(e => e.Refresh(It.IsAny()), Times.Exactly(1)); + } + + [TestMethod("Insert commit fires inserted event after notifying cache")] + public async Task Insert_Commit_InvokedInsertedEvent_AfterNotifyingCache() + { + /* + * Setup + * Mockbehavior string ensures no unplanned functions are called on the services. + * If you receive an error about function calls not properly set up, and function + * calls look ok, it's probably the sequence throwing the exception. This is a + * failure in user code, not the test. + */ + var sequence = new MockSequence(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake + .InSequence(sequence) + .Setup(e => e.Refresh(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake + .InSequence(sequence) + .Setup(e => e.Enqueue(It.IsAny(), ServiceEvents.Inserted, It.IsAny())); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + taxRateCacheFake.Verify(e => e.Refresh(It.IsAny()), Times.Exactly(1)); + eventServiceFake.Verify(e => e.Enqueue(It.IsAny(), ServiceEvents.Inserted, It.IsAny())); + } + } +} \ No newline at end of file diff --git a/Common.Types.Tests/TaxRates/Ops/LookupTests.cs b/Common.Types.Tests/TaxRates/Ops/LookupTests.cs new file mode 100644 index 0000000..128a41c --- /dev/null +++ b/Common.Types.Tests/TaxRates/Ops/LookupTests.cs @@ -0,0 +1,241 @@ +using Connected.Entities; +using Connected.ServiceModel; +using System.Collections.Immutable; +using System.Data; +using Lookup = Common.Types.TaxRates.TaxRateOps.Lookup; + +namespace Common.Types.TaxRates.Ops; + + +[TestClass] +public class LookupTests +{ + [TestMethod("Lookup invoke returns ImmutableList when set is empty")] + public async Task Lookup_Invoke_ReturnsImmutableList_WhenEmpty() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetEmptyTaxRateSet(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + + var args = new PrimaryKeyListArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.GetType().IsAssignableToGenericType(typeof(ImmutableList<>))); + } + + [TestMethod("Lookup invoke returns empty set when argument list is empty")] + public async Task Lookup_Invoke_ReturnsEmptyImmutableList_WhenArgumentListEmpty() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.CombinedTaxRateSet; + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + + var args = new PrimaryKeyListArgs + { + IdList = new List() + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.Count == 0); + } + + [TestMethod("Lookup invoke returns empty set when argument list is invalid")] + public async Task Lookup_Invoke_ReturnsEmptyImmutableList_WhenArgumentListInvalid() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.CombinedTaxRateSet; + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + + var args = new PrimaryKeyListArgs(); + /* + * Test + */ + var serviceFunction = new Lookup(taxRateCacheFake.Object); + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.Count == 0); + } + + [TestMethod("Lookup invoke returns correct set when argument list is valid")] + public async Task Lookup_Invoke_ReturnsCorrectSet_WhenArgumentListValid() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.CombinedTaxRateSet; + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + + var args = new PrimaryKeyListArgs + { + IdList = new[] + { + TaxRateSamples.NotModifiedEnabled.Id, + TaxRateSamples.NewEnabled.Id, + TaxRateSamples.NotModifiedDisabled.Id + } + }; + + var expected = new[] + { + TaxRateSamples.NotModifiedEnabled, + TaxRateSamples.NewEnabled, + TaxRateSamples.NotModifiedDisabled + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.SequenceEquivalent(expected)); + } + + [TestMethod("Lookup invoke returns correct set when argument list contains deleted values")] + public async Task Lookup_Invoke_ReturnsCorrectSet_WhenArgumentListContainsDeletedValues() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.CombinedTaxRateSet; + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + + var args = new PrimaryKeyListArgs + { + IdList = new[] + { + TaxRateSamples.NotModifiedEnabled.Id, + TaxRateSamples.DeletedEnabled.Id + } + }; + + var expected = new[] + { + TaxRateSamples.NotModifiedEnabled + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.SequenceEquivalent(expected)); + } + + [TestMethod("Lookup invoke returns all entries when no entries are deleted")] + public async Task Lookup_Invoke_ReturnsAllEntries_WhenNoEntriesAreDeleted() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetTaxRates(State.Default) + .Union(TaxRateSamples.GetTaxRates(State.New)); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + + var args = new PrimaryKeyListArgs + { + IdList = dataSet.Select(e => e.Id) + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.SequenceEqual(dataSet)); + } + + [TestMethod("Lookup invoke returns only non deleted entries when entries are deleted")] + public async Task Lookup_Invoke_ReturnsNonDeletedOnly_WhenEntriesAreDeleted() + { + /* + * Setup + */ + var expectedDataSet = TaxRateSamples.GetTaxRates(State.Default) + .Union(TaxRateSamples.GetTaxRates(State.New)); + + var completeDataSet = expectedDataSet.Union(TaxRateSamples.GetTaxRates(State.Deleted)); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(completeDataSet); + + var args = new PrimaryKeyListArgs + { + IdList = completeDataSet.Select(e => e.Id) + }; + /* + * Test + */ + var serviceFunction = new Lookup(taxRateCacheFake.Object); + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.SequenceEqual(expectedDataSet)); + } +} \ No newline at end of file diff --git a/Common.Types.Tests/TaxRates/Ops/QueryTests.cs b/Common.Types.Tests/TaxRates/Ops/QueryTests.cs new file mode 100644 index 0000000..d3b8515 --- /dev/null +++ b/Common.Types.Tests/TaxRates/Ops/QueryTests.cs @@ -0,0 +1,96 @@ +using Connected.Entities; +using Connected.ServiceModel; +using System.Collections.Immutable; +using static Common.Types.TaxRates.TaxRateOps; + +namespace Common.Types.TaxRates.Ops; + + +[TestClass] +public class QueryTests +{ + [TestMethod("Query invoke returns ImmutableList when set is empty")] + public async Task Query_Invoke_ReturnsImmutableList_WhenEmpty() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetEmptyTaxRateSet(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + + var args = QueryArgs.Default; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.GetType().IsAssignableToGenericType(typeof(ImmutableList<>))); + } + + [TestMethod("Query invoke returns all entries when no entries are deleted")] + public async Task Query_Invoke_ReturnsAllEntries_WhenNoEntriesAreDeleted() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetTaxRates(State.Default) + .Union(TaxRateSamples.GetTaxRates(State.New)); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + + var args = Dto.Empty; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(QueryArgs.Default); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.SequenceEqual(dataSet)); + } + + [TestMethod("Query invoke returns only non deleted entries when entries are deleted")] + public async Task Query_Invoke_ReturnsNonDeletedOnly_WhenEntriesAreDeleted() + { + /* + * Setup + */ + var expectedDataSet = TaxRateSamples.GetTaxRates(State.Default) + .Union(TaxRateSamples.GetTaxRates(State.New)); + + var deletedDataSet = expectedDataSet.Union(TaxRateSamples.GetTaxRates(State.Deleted)); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(deletedDataSet); + + var args = Dto.Empty; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(QueryArgs.Default); + /* + * Assert + */ + Assert.IsNotNull(result); + Assert.IsTrue(result.SequenceEqual(expectedDataSet)); + } +} \ No newline at end of file diff --git a/Common.Types.Tests/TaxRates/Ops/SelectByRateTests.cs b/Common.Types.Tests/TaxRates/Ops/SelectByRateTests.cs new file mode 100644 index 0000000..3ef79f4 --- /dev/null +++ b/Common.Types.Tests/TaxRates/Ops/SelectByRateTests.cs @@ -0,0 +1,176 @@ +using Connected.Entities; +using static Common.Types.TaxRates.TaxRateOps; + +namespace Common.Types.TaxRates.Ops; + +[TestClass] +public class SelectByRateTests +{ + private static IEnumerable TaxRates => TaxRateSamples.CombinedTaxRateSet + .Select(e => new object[] { e.Rate, e }) + .ToList(); + + private static IEnumerable DeletedTaxRates => TaxRateSamples.CombinedTaxRateSet + .Where(e => e.State == State.Deleted) + .Select(e => new object[] { e.Rate, e }) + .ToList(); + + [TestMethod("SelectByRate invoke returns null when rate does not exist")] + public async Task Select_Invoke_ReturnsNull_WhenRateInvalid() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.CombinedTaxRateSet; + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + /* + * Ensure id is always out of range + */ + var outOfRangeRate = dataSet.Select(e => e.Rate).DefaultIfEmpty(0).Max() + 1; + + var args = new TaxRateArgs + { + Rate = outOfRangeRate + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNull(result); + } + + [TestMethod("SelectByRate invoke returns null when rate not specified")] + public async Task Select_Invoke_ReturnsNull_WhenRateNotSpecified() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.CombinedTaxRateSet; + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + /* + * Ensure id is always out of range + */ + var args = new TaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNull(result); + } + + [TestMethod("SelectByRate invoke returns null when set is empty")] + public async Task Select_Invoke_ReturnsNull_WhenSetEmpty() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetEmptyTaxRateSet(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + + var args = new TaxRateArgs + { + Rate = 1 + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNull(result); + } + + [TestMethod("SelectByRate invoke returns value when rate exists")] + [DynamicData(nameof(TaxRates))] + public async Task Select_Invoke_ReturnsValue_WhenRateValid(float rate, ITaxRate expected) + { + /* + * Setup + */ + var dataSet = TaxRates + .Select(e => e[1] as TaxRate) + .ToList(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + /* + * Ensure id is always out of range + */ + var args = new TaxRateArgs + { + Rate = rate + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.AreSame(result, expected); + } + + [TestMethod("Select invoke returns null value when rate exists but is deleted")] + [DynamicData(nameof(DeletedTaxRates))] + public async Task Select_Invoke_ReturnsNull_WhenRateValidAndDeleted(float rate) + { + /* + * Setup + */ + var dataSet = TaxRates + .Select(e => e[1] as TaxRate) + .ToList(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + /* + * Ensure id is always out of range + */ + var args = new TaxRateArgs + { + Rate = rate + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNull(result); + } +} + diff --git a/Common.Types.Tests/TaxRates/Ops/SelectTests.cs b/Common.Types.Tests/TaxRates/Ops/SelectTests.cs new file mode 100644 index 0000000..e558e77 --- /dev/null +++ b/Common.Types.Tests/TaxRates/Ops/SelectTests.cs @@ -0,0 +1,139 @@ +using Connected.Entities; +using Connected.ServiceModel; +using static Common.Types.TaxRates.TaxRateOps; + +namespace Common.Types.TaxRates.Ops; + +[TestClass] +public class SelectTests +{ + private static IEnumerable TaxRates => TaxRateSamples.CombinedTaxRateSet + .Select(e => new object[] { e.Id, e }) + .ToList(); + private static IEnumerable DeletedTaxRates => TaxRateSamples.CombinedTaxRateSet + .Where(e => e.State == State.Deleted) + .Select(e => new object[] { e.Id, e }) + .ToList(); + + [TestMethod("Select invoke returns null when id does not exist")] + public async Task Select_Invoke_ReturnsNull_WhenIdInvalid() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.CombinedTaxRateSet; + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake + .As>() + .Setup(e => e.GetEnumerator()) + .Returns(dataSet.GetEnumerator()); + /* + * Ensure id is always out of range + */ + var args = new PrimaryKeyArgs(1); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.IsNull(result); + } + + [TestMethod("Select invoke returns value when id exists")] + [DynamicData(nameof(TaxRates))] + public async Task Select_Invoke_ReturnsValue_WhenIdValid(int id, ITaxRate expected) + { + /* + * Setup + */ + var dataSet = TaxRates.Select(e => e[1] as TaxRate).ToList(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet!); + /* + * Ensure id is always out of range + */ + var args = new PrimaryKeyArgs(id); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + var result = await serviceFunction.Invoke(args); + /* + * Assert + */ + Assert.AreSame(result, expected); + } +} + diff --git a/Common.Types.Tests/TaxRates/Ops/TaxRateSamples.cs b/Common.Types.Tests/TaxRates/Ops/TaxRateSamples.cs new file mode 100644 index 0000000..d5c5fd5 --- /dev/null +++ b/Common.Types.Tests/TaxRates/Ops/TaxRateSamples.cs @@ -0,0 +1,74 @@ +using Connected.Entities; + +namespace Common.Types.TaxRates.Ops; + +internal class TaxRateSamples +{ + internal static IEnumerable GetEmptyTaxRateSet() => new List { }; + + internal static IEnumerable GetTaxRates(State state) => CombinedTaxRateSet.Where(e => e.State == state); + + public static TaxRate NewEnabled = new() + { + Id = 1, + Name = nameof(NewEnabled), + Rate = 10, + State = State.New, + Status = Connected.Data.Status.Enabled + }; + + public static TaxRate NotModifiedEnabled = new() + { + Id = 3, + Name = nameof(NotModifiedEnabled), + Rate = 30, + State = State.Default, + Status = Connected.Data.Status.Enabled + }; + + public static TaxRate DeletedEnabled = new() + { + Id = 4, + Name = nameof(DeletedEnabled), + Rate = 40, + State = State.Deleted, + Status = Connected.Data.Status.Enabled + }; + + public static TaxRate NewDisabled = new() + { + Id = 5, + Name = nameof(NewDisabled), + Rate = 50, + State = State.New, + Status = Connected.Data.Status.Disabled + }; + + public static TaxRate NotModifiedDisabled = new() + { + Id = 7, + Name = nameof(NotModifiedDisabled), + Rate = 70, + State = State.Default, + Status = Connected.Data.Status.Disabled + }; + + public static TaxRate DeletedDisabled = new() + { + Id = 8, + Name = nameof(DeletedDisabled), + Rate = 80, + State = State.Deleted, + Status = Connected.Data.Status.Disabled + }; + + internal static IEnumerable CombinedTaxRateSet => new List + { + NewEnabled, + NotModifiedEnabled, + DeletedEnabled, + NewDisabled, + NotModifiedDisabled, + DeletedDisabled + }; +} diff --git a/Common.Types.Tests/TaxRates/Ops/UpdateTests.cs b/Common.Types.Tests/TaxRates/Ops/UpdateTests.cs new file mode 100644 index 0000000..74a9b87 --- /dev/null +++ b/Common.Types.Tests/TaxRates/Ops/UpdateTests.cs @@ -0,0 +1,314 @@ +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.ServiceModel.Transactions; +using Moq; +using System.Data; +using System.Linq.Expressions; +using static Common.Types.TaxRates.TaxRateOps; +using static Common.Types.TestUtils; + +namespace Common.Types.TaxRates.Ops; + +public class UpdateTests +{ + [TestClass] + public class OnInvoke + { + private static IEnumerable TaxRates => TaxRateSamples.CombinedTaxRateSet + .Select(e => new object[] { e.Id, e }) + .ToList(); + + [TestMethod("Update updates only matching id")] + [DynamicData(nameof(TaxRates))] + public async Task Update_Invoke_UpdatesMatchingEntity(int id, ITaxRate expected) + { + /* + * Setup + */ + var dataSet = TaxRates.Select(e => e[1] as TaxRate).ToList(); + + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + Expression> updateFunction = (IStorageProvider e) => e.Open().Update(It.IsAny(), It.IsAny(), It.IsAny>>()); + + TaxRate? updatedEntity = null; + + _ = databaseContextProviderFake + .Setup(updateFunction) + .Callback>>((e, _, _) => updatedEntity = e); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + + var args = new UpdateTaxRateArgs + { + Id = id + }; + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await serviceFunction.Invoke(args); + /* + * Assert + */ + databaseContextProviderFake.Verify(updateFunction, Times.Once()); + Assert.AreEqual(expected, updatedEntity); + } + + [TestMethod("Update throws if tax rate does not exist")] + public async Task Update_Invoke_ThrowsException_WhenEntityDoesNotExist() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetEmptyTaxRateSet(); + + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + Expression> updateFunction = (IStorageProvider e) => e.Open().Update(It.IsAny(), It.IsAny(), It.IsAny>>()); + + _ = databaseContextProviderFake.Setup(updateFunction); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + /* + * Generate empty arguments, as the dataset is empty either way + */ + var args = new UpdateTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + /* + * Assert + */ + await AssertExtensions.Throws(async () => await serviceFunction.Invoke(args)); + } + + [TestMethod("Update invokes concurrent update exactly once")] + public async Task Update_Invoke_InvokesConcurrentUpdate_ExactlyOnce() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetEmptyTaxRateSet(); + + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + Expression> updateFunction = (IStorageProvider e) => e.Open().Update(It.IsAny(), It.IsAny(), It.IsAny>>()); + + _ = databaseContextProviderFake.Setup(updateFunction); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + /* + * Arguments are irrelevant for this test + */ + var args = new UpdateTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await serviceFunction.Invoke(args); + /* + * Assert + */ + databaseContextProviderFake.Verify(updateFunction, Times.Exactly(1)); + } + + [TestMethod("Update loads data exactly once when version is current")] + public async Task Update_Invoke_LoadsEntityExactlyOnce_WhenVersionIsCurrent() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetEmptyTaxRateSet(); + + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + Expression> updateFunction = (IStorageProvider e) => e.Open().Update(It.IsAny(), It.IsAny(), It.IsAny>>()); + + _ = databaseContextProviderFake.Setup(updateFunction); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + + /* + * Arguments are irrelevant for this test + */ + var args = new UpdateTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await serviceFunction.Invoke(args); + /* + * Assert + */ + taxRateCacheFake.Verify(e => e.GetEnumerator(), Times.Exactly(1)); + } + + [TestMethod("Update loads data twice when version is mismatched")] + public async Task Update_Invoke_LoadsEntityExactlyTwice_WhenVersionIsMismatched() + { + /* + * Setup + */ + var dataSet = TaxRateSamples.GetEmptyTaxRateSet(); + + var instanceFaker = new InstanceFaker(); + + var databaseContextProviderFake = instanceFaker.GetMock()!; + + Expression> updateFunction = (IStorageProvider e) => e.Open().Update(It.IsAny(), It.IsAny(), It.IsAny>>()); + + /* + * Simulate version mismatch by invoking the retry once + */ + _ = databaseContextProviderFake.Setup(updateFunction).Callback>>((_, _, e) => e.Invoke()); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + taxRateCacheFake.SetupIEnumerable(dataSet); + + /* + * Arguments are irrelevant for this test + */ + var args = new UpdateTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + await serviceFunction.Invoke(args); + /* + * Assert + */ + taxRateCacheFake.Verify(e => e.GetEnumerator(), Times.Exactly(2)); + } + } + + [TestClass] + public class OnCommit + { + [TestMethod("Update commit invokes updated event exactly once")] + public async Task Update_Commit_InvokedUpdated_ExactlyOnce() + { + /* + * Setup + */ + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake.Setup(e => e.Refresh(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake.Setup(e => e.Enqueue(It.IsAny(), ServiceEvents.Updated, It.IsAny())); + + /* + * Argument values are irrelevant for the test + */ + var args = new UpdateTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + serviceFunction.SetArguments(args); + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + eventServiceFake.Verify(e => e.Enqueue(It.IsAny(), ServiceEvents.Updated, It.IsAny()), Times.Exactly(1)); + } + + [TestMethod("Update commit refreshes cache exactly once")] + public async Task Update_Commit_RefreshesCache_ExactlyOnce() + { + /* + * Setup + */ + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake.Setup(e => e.Refresh(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake.Setup(e => e.Enqueue(It.IsAny(), ServiceEvents.Updated, It.IsAny())); + + /* + * Argument values are irrelevant for the test + */ + var args = new UpdateTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + serviceFunction.SetArguments(args); + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + taxRateCacheFake.Verify(e => e.Refresh(It.IsAny()), Times.Exactly(1)); + } + + [TestMethod("Update commit fires updated event after notifying cache")] + public async Task Update_Commit_InvokedLastUpdatedEvent_AfterNotifyingCache() + { + /* + * Setup + * Mockbehavior string ensures no unplanned functions are called on the services. + * If you receive an error about function calls not properly set up, and function + * calls look ok, it's probably the sequence throwing the exception. This is a + * failure in user code, not the test. + */ + var sequence = new MockSequence(); + + var instanceFaker = new InstanceFaker(); + + var taxRateCacheFake = instanceFaker.GetMock()!; + + _ = taxRateCacheFake + .InSequence(sequence) + .Setup(e => e.Refresh(It.IsAny())); + + var eventServiceFake = instanceFaker.GetMock()!; + + _ = eventServiceFake + .InSequence(sequence) + .Setup(e => e.Enqueue(It.IsAny(), ServiceEvents.Updated, It.IsAny())); + + /* + * Argument values are irrelevant for the test + */ + var args = new UpdateTaxRateArgs(); + /* + * Test + */ + var serviceFunction = instanceFaker.Instance; + serviceFunction.SetArguments(args); + await ((ITransactionClient)serviceFunction).Commit(); + /* + * Assert + */ + taxRateCacheFake.Verify(e => e.Refresh(It.IsAny()), Times.AtLeastOnce()); + eventServiceFake.VerifyEvent(ServiceEvents.Updated); + } + } +} \ No newline at end of file diff --git a/Common.Types.Tests/TaxRates/TaxRateTests.cs b/Common.Types.Tests/TaxRates/TaxRateTests.cs new file mode 100644 index 0000000..d79c364 --- /dev/null +++ b/Common.Types.Tests/TaxRates/TaxRateTests.cs @@ -0,0 +1,35 @@ +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using System.Reflection; + +namespace Common.Types.TaxRates; + +[TestClass] +public class TaxRateTests +{ + [TestMethod("TaxRate is ConsistentEntity<>")] + public void TaxRate_IsConsistentEntity() + { + /* + * Test + */ + var isConsistentEntity = typeof(TaxRate).IsAssignableToGenericType(typeof(ConsistentEntity<>)); + /* + * Assert + */ + Assert.IsTrue(isConsistentEntity); + } + + [TestMethod("TaxRate has exactly one schema")] + public void TaxRate_HasExactlyOneSchema() + { + /* + * Test + */ + var tableAttributes = typeof(TaxRate).GetCustomAttributes(); + /* + * Assert + */ + Assert.IsTrue(tableAttributes.Count() == 1); + } +} \ No newline at end of file diff --git a/Common.Types.Tests/TestUtils.cs b/Common.Types.Tests/TestUtils.cs new file mode 100644 index 0000000..6a8f490 --- /dev/null +++ b/Common.Types.Tests/TestUtils.cs @@ -0,0 +1,105 @@ +using Connected; +using Connected.Notifications.Events; +using Connected.Services; + +using Moq; + +namespace Common.Types; + +internal static class TestUtils +{ + public static bool IsAssignableToGenericType(this Type type) + { + var genericType = typeof(TGenericType); + + if (!genericType.IsGenericType) + throw new ArgumentException("The passed type must be a generic type.", nameof(TGenericType)); + + return type.IsAssignableToGenericType(genericType); + } + + public static bool IsAssignableToGenericType(this Type type, Type genericType) + { + if (!genericType.IsGenericType) + throw new ArgumentException("The passed type must be a generic type.", nameof(genericType)); + + var interfaceTypes = type.GetInterfaces(); + + foreach (var interfaceType in interfaceTypes) + { + if (interfaceType.IsGenericType && interfaceType.GetGenericTypeDefinition() == genericType) + return true; + } + + if (type.IsGenericType && type.GetGenericTypeDefinition() == genericType) + return true; + + var baseType = type.BaseType; + + if (baseType is null) + return false; + + return baseType.IsAssignableToGenericType(genericType); + } + + public static bool SequenceEquivalent(this IEnumerable firstSequence, IEnumerable secondSequence) + { + var diff1 = firstSequence.Except(secondSequence); + var diff2 = secondSequence.Except(firstSequence); + + return !diff1.Any() && !diff2.Any(); + } + + public static void SetArguments(this ServiceOperation operation, TArgs args) + where TArgs : IDto + { + typeof(ServiceOperation).GetProperty(nameof(operation.Arguments))!.SetValue(operation, args); + } + + public static Moq.Language.Flow.ISetup SetupEvent(this Moq.Language.ISetupConditionResult setup, string @event) + { + return setup.Setup(e => e.Enqueue(It.IsAny(), @event, It.IsAny())); + } + + public static Moq.Language.Flow.ISetup SetupEvent(this Mock setup, string @event) + { + return setup.Setup(e => e.Enqueue(It.IsAny(), @event, It.IsAny())); + } + + public static void VerifyEvent(this Mock setup, string @event) + { + setup.Verify(e => e.Enqueue(It.IsAny(), @event, It.IsAny()), Times.Exactly(1)); + } + + public static void SetupIEnumerable(this Mock mock, IEnumerable data) + { + mock.As>() + .Setup(e => e.GetEnumerator()) + .Returns(data.GetEnumerator()); + } + + public static class AssertExtensions + { + public static async Task Throws(Task action) + { + Exception exception = null; + + try + { + await action; + } + catch (Exception ex) + { + exception = ex; + } + + if (exception is null) + Assert.Fail("Expected exception, none was thrown."); + } + + internal static Task Throws(Func value) + { + return Throws(value()); + } + } +} diff --git a/Common.Types.Tests/Usings.cs b/Common.Types.Tests/Usings.cs new file mode 100644 index 0000000..ab67c7e --- /dev/null +++ b/Common.Types.Tests/Usings.cs @@ -0,0 +1 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; \ No newline at end of file diff --git a/Common.Types.UI/Bootstrapper.cs b/Common.Types.UI/Bootstrapper.cs new file mode 100644 index 0000000..d126e84 --- /dev/null +++ b/Common.Types.UI/Bootstrapper.cs @@ -0,0 +1,18 @@ +using Connected.Startup; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Common.Types; + +public class Bootstrapper : IStartup +{ + public async Task Configure(WebAssemblyHost host) + { + await Task.CompletedTask; + } + + public async Task ConfigureServices(IServiceCollection services) + { + await Task.CompletedTask; + } +} diff --git a/Common.Types.UI/Common.Types.UI.csproj b/Common.Types.UI/Common.Types.UI.csproj new file mode 100644 index 0000000..5bd472b --- /dev/null +++ b/Common.Types.UI/Common.Types.UI.csproj @@ -0,0 +1,30 @@ + + + + net7.0 + enable + enable + Common.Types + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Common.Types.UI/Routes.cs b/Common.Types.UI/Routes.cs new file mode 100644 index 0000000..8257a55 --- /dev/null +++ b/Common.Types.UI/Routes.cs @@ -0,0 +1,12 @@ +using S = Connected.UIRoutes; + +namespace Common.Types; + +public static class CommonTypesUIRoutes +{ + public const string CommonTypes = $"{S.Management}/commonTypes"; + + public const string TaxRates = $"{CommonTypes}/taxRates"; + public const string TaxRatesAdd = $"{CommonTypes}/taxRates/add"; + public const string TaxRatesEdit = $"{CommonTypes}/taxRates/edit/{{id:int}}"; +} diff --git a/Common.Types.UI/TaxRates/Components/TaxRateAdd.razor b/Common.Types.UI/TaxRates/Components/TaxRateAdd.razor new file mode 100644 index 0000000..abfe1a8 --- /dev/null +++ b/Common.Types.UI/TaxRates/Components/TaxRateAdd.razor @@ -0,0 +1,24 @@ +@using Connected.Components; + +@inherits UIComponent + +

TaxRateAdd

+ + + + + +
+ + +
+ +
+ + +
+ + + + +
\ No newline at end of file diff --git a/Common.Types.UI/TaxRates/Components/TaxRateAdd.razor.cs b/Common.Types.UI/TaxRates/Components/TaxRateAdd.razor.cs new file mode 100644 index 0000000..1d3427a --- /dev/null +++ b/Common.Types.UI/TaxRates/Components/TaxRateAdd.razor.cs @@ -0,0 +1,39 @@ +using Connected.Components; +using Microsoft.AspNetCore.Components; + +namespace Common.Types.TaxRates.Components; + +public partial class TaxRateAdd : UIComponent +{ + public TaxRateAdd() + { + DataSource = new InsertTaxRateArgs(); + } + + private InsertTaxRateArgs DataSource { get; } + + [Inject] + private ITaxRateService? TaxRateService { get; set; } + + [Inject] + private NavigationManager? Navigation { get; set; } + + protected override async Task OnParametersSetAsync() + { + if (TaxRateService is null) + throw new ArgumentException(null, nameof(TaxRateService)); + + await base.OnParametersSetAsync(); + } + protected virtual async void OnInsert() + { + await TaxRateService.Insert(DataSource); + + Navigation.NavigateTo(Routes.TaxRates); + } + + protected virtual void OnCancel() + { + Navigation.NavigateTo(Routes.TaxRates); + } +} \ No newline at end of file diff --git a/Common.Types.UI/TaxRates/Components/TaxRateEdit.razor b/Common.Types.UI/TaxRates/Components/TaxRateEdit.razor new file mode 100644 index 0000000..a9438e4 --- /dev/null +++ b/Common.Types.UI/TaxRates/Components/TaxRateEdit.razor @@ -0,0 +1,25 @@ +@using Connected.Components; + +@inherits UIComponent + + +

TaxRateEditForm

+ + + + + +
+ + +
+ +
+ + +
+ + + + +
\ No newline at end of file diff --git a/Common.Types.UI/TaxRates/Components/TaxRateEdit.razor.cs b/Common.Types.UI/TaxRates/Components/TaxRateEdit.razor.cs new file mode 100644 index 0000000..e6508d9 --- /dev/null +++ b/Common.Types.UI/TaxRates/Components/TaxRateEdit.razor.cs @@ -0,0 +1,42 @@ +using Connected.Components; +using Connected.ServiceModel; +using Microsoft.AspNetCore.Components; + +namespace Common.Types.TaxRates.Components; + +public partial class TaxRateEdit : UIComponent +{ + [Parameter] + public int Id { get; set; } + private UpdateTaxRateArgs? DataSource { get; set; } + + [Inject] + private ITaxRateService? TaxRateService { get; set; } + + [Inject] + private NavigationManager? Navigation { get; set; } + + protected override async Task OnParametersSetAsync() + { + var taxRate = await TaxRateService.Select(new PrimaryKeyArgs { Id = Convert.ToInt32(Id) }); + + DataSource = new UpdateTaxRateArgs + { + Id = taxRate.Id, + Name = taxRate.Name, + Rate = taxRate.Rate + }; + } + + private async void OnUpdate() + { + await TaxRateService.Update(DataSource); + + Navigation.NavigateTo(CommonTypesUIRoutes.TaxRates); + } + + private void OnCancel() + { + Navigation.NavigateTo(CommonTypesUIRoutes.TaxRates); + } +} diff --git a/Common.Types.UI/TaxRates/Components/TaxRatesList.razor b/Common.Types.UI/TaxRates/Components/TaxRatesList.razor new file mode 100644 index 0000000..f36e213 --- /dev/null +++ b/Common.Types.UI/TaxRates/Components/TaxRatesList.razor @@ -0,0 +1,46 @@ +@using Connected.Components; + +@inherits UIComponent + +@if (DataSource is null) +{ +
+ loading tax rates... +
+ return; +} + + +
+ +
+ +
+ + + + + + + + + @foreach (var taxRate in DataSource) + { + + + + + + + } +
IdNameRate...
+ @taxRate.Id + + @taxRate.Name + + @taxRate.Rate +
+ +
+ +
diff --git a/Common.Types.UI/TaxRates/Components/TaxRatesList.razor.cs b/Common.Types.UI/TaxRates/Components/TaxRatesList.razor.cs new file mode 100644 index 0000000..6ac5609 --- /dev/null +++ b/Common.Types.UI/TaxRates/Components/TaxRatesList.razor.cs @@ -0,0 +1,44 @@ +using System.Collections.Immutable; +using Connected.Components; +using Connected.Notifications; +using Connected.ServiceModel; +using Microsoft.AspNetCore.Components; + +namespace Common.Types.TaxRates.Components; + +public partial class TaxRatesList : UIComponent +{ + private ImmutableList? DataSource { get; set; } + + [Inject] + private ITaxRateService? TaxRateService { get; set; } + [Inject] + private NavigationManager? Navigation { get; set; } + + private string LastInsert { get; set; } = "waiting..."; + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + TaxRateService.Inserted += OnInserted; + + DataSource = await TaxRateService.Query(QueryArgs.Default); + } + + private void OnInserted(object? sender, PrimaryKeyEventArgs e) + { + LastInsert = $"{e.Id} -> {DateTime.UtcNow:F}"; + StateHasChanged(); + } + + private void OnEdit(int id) + { + Navigation.NavigateTo(CommonTypesUIRoutes.TaxRatesEdit.Replace("{id:int}", id.ToString())); + } + + private void OnAdd() + { + Navigation.NavigateTo(CommonTypesUIRoutes.TaxRatesAdd); + } +} diff --git a/Common.Types.UI/TaxRates/Pages/TaxRateAddPage.razor b/Common.Types.UI/TaxRates/Pages/TaxRateAddPage.razor new file mode 100644 index 0000000..0acaca1 --- /dev/null +++ b/Common.Types.UI/TaxRates/Pages/TaxRateAddPage.razor @@ -0,0 +1,5 @@ +@attribute [Route(CommonTypesUIRoutes.TaxRatesAdd)] + +

TaxRateAdd

+ + \ No newline at end of file diff --git a/Common.Types.UI/TaxRates/Pages/TaxRateEditPage.razor b/Common.Types.UI/TaxRates/Pages/TaxRateEditPage.razor new file mode 100644 index 0000000..4e75fa1 --- /dev/null +++ b/Common.Types.UI/TaxRates/Pages/TaxRateEditPage.razor @@ -0,0 +1,11 @@ +@using Common.Types.TaxRates.Components +@attribute [Route(CommonTypesUIRoutes.TaxRatesEdit)] + +

TaxRateEdit

+ + + +@code { + [Parameter] + public int Id { get; set; } +} \ No newline at end of file diff --git a/Common.Types.UI/TaxRates/Pages/TaxRatesPage.razor b/Common.Types.UI/TaxRates/Pages/TaxRatesPage.razor new file mode 100644 index 0000000..9edf046 --- /dev/null +++ b/Common.Types.UI/TaxRates/Pages/TaxRatesPage.razor @@ -0,0 +1,12 @@ +@attribute [Route(CommonTypesUIRoutes.TaxRates)] + +@using Common.Types.TaxRates.Components; +@using Connected.Components; +@using Connected.Layouts; + +@inherits UIComponent +@layout DefaultLayout + +

Tax rates

+ + \ No newline at end of file diff --git a/Common.Types.UI/_Imports.razor b/Common.Types.UI/_Imports.razor new file mode 100644 index 0000000..b4ebe05 --- /dev/null +++ b/Common.Types.UI/_Imports.razor @@ -0,0 +1,6 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop \ No newline at end of file diff --git a/Common.Types.sln b/Common.Types.sln new file mode 100644 index 0000000..0c67299 --- /dev/null +++ b/Common.Types.sln @@ -0,0 +1,122 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.32916.344 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Dependencies", "Dependencies", "{20087506-E20F-4FD6-ADFB-9D7ADBBD3998}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Types.Client", "Common.Types.Client\Common.Types.Client.csproj", "{B94A8E27-3682-4321-9BF7-F0A2D775E905}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Types.Middleware", "Common.Types.Middleware\Common.Types.Middleware.csproj", "{72B591CC-DAC3-4F9A-A95C-67C265FB4E93}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Types", "Common.Types\Common.Types.csproj", "{EDDF95B7-236C-4F87-A9F0-1BC5A7FA9980}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Types.UI", "Common.Types.UI\Common.Types.UI.csproj", "{62A9D5D4-942B-40DA-AA9C-CF3D48339B60}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Data", "..\Framework\Connected.Data\Connected.Data.csproj", "{29DC3FA3-643B-4E92-B176-C6594960AD4B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Middleware", "..\Framework\Connected.Middleware\Connected.Middleware.csproj", "{1E4DFB97-CD50-4836-8B62-46D6FBDF924C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Services", "..\Framework\Connected.Services\Connected.Services.csproj", "{90AD4721-1C34-4B9A-9515-6AFC095F55E8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected", "..\Connected\Connected\Connected.csproj", "{B19801B5-7C84-4669-92AF-7581ABBDE259}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.UI", "..\Connected\Connected.UI\Connected.UI.csproj", "{105164C0-685F-420F-B822-D59965244E51}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Client", "..\Connected\Connected.Client\Connected.Client.csproj", "{EA8DA8CF-9DE4-4815-93C3-ED92A6B5E64D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Types.Model", "Common.Types.Model\Common.Types.Model.csproj", "{DB82BD88-23C1-4E8A-9E48-92848E1942C0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Model", "..\Common\Common.Model\Common.Model.csproj", "{03D41846-3257-4097-9145-21DDA9546833}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Runtime", "..\Framework\Connected.Runtime\Connected.Runtime.csproj", "{7E147EBC-5B8C-462F-B736-545167935CB3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Types.Tests", "Common.Types.Tests\Common.Types.Tests.csproj", "{AEF4D71A-C334-4E13-AECD-8EEA06872BB0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Entities", "..\Framework\Connected.Entities\Connected.Entities.csproj", "{E52DDB90-5F33-4189-8D72-81DE8165ADE6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B94A8E27-3682-4321-9BF7-F0A2D775E905}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B94A8E27-3682-4321-9BF7-F0A2D775E905}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B94A8E27-3682-4321-9BF7-F0A2D775E905}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B94A8E27-3682-4321-9BF7-F0A2D775E905}.Release|Any CPU.Build.0 = Release|Any CPU + {72B591CC-DAC3-4F9A-A95C-67C265FB4E93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72B591CC-DAC3-4F9A-A95C-67C265FB4E93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72B591CC-DAC3-4F9A-A95C-67C265FB4E93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72B591CC-DAC3-4F9A-A95C-67C265FB4E93}.Release|Any CPU.Build.0 = Release|Any CPU + {EDDF95B7-236C-4F87-A9F0-1BC5A7FA9980}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EDDF95B7-236C-4F87-A9F0-1BC5A7FA9980}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDDF95B7-236C-4F87-A9F0-1BC5A7FA9980}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EDDF95B7-236C-4F87-A9F0-1BC5A7FA9980}.Release|Any CPU.Build.0 = Release|Any CPU + {62A9D5D4-942B-40DA-AA9C-CF3D48339B60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62A9D5D4-942B-40DA-AA9C-CF3D48339B60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62A9D5D4-942B-40DA-AA9C-CF3D48339B60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62A9D5D4-942B-40DA-AA9C-CF3D48339B60}.Release|Any CPU.Build.0 = Release|Any CPU + {29DC3FA3-643B-4E92-B176-C6594960AD4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29DC3FA3-643B-4E92-B176-C6594960AD4B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29DC3FA3-643B-4E92-B176-C6594960AD4B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29DC3FA3-643B-4E92-B176-C6594960AD4B}.Release|Any CPU.Build.0 = Release|Any CPU + {1E4DFB97-CD50-4836-8B62-46D6FBDF924C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E4DFB97-CD50-4836-8B62-46D6FBDF924C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E4DFB97-CD50-4836-8B62-46D6FBDF924C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E4DFB97-CD50-4836-8B62-46D6FBDF924C}.Release|Any CPU.Build.0 = Release|Any CPU + {90AD4721-1C34-4B9A-9515-6AFC095F55E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90AD4721-1C34-4B9A-9515-6AFC095F55E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90AD4721-1C34-4B9A-9515-6AFC095F55E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90AD4721-1C34-4B9A-9515-6AFC095F55E8}.Release|Any CPU.Build.0 = Release|Any CPU + {B19801B5-7C84-4669-92AF-7581ABBDE259}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B19801B5-7C84-4669-92AF-7581ABBDE259}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B19801B5-7C84-4669-92AF-7581ABBDE259}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B19801B5-7C84-4669-92AF-7581ABBDE259}.Release|Any CPU.Build.0 = Release|Any CPU + {105164C0-685F-420F-B822-D59965244E51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {105164C0-685F-420F-B822-D59965244E51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {105164C0-685F-420F-B822-D59965244E51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {105164C0-685F-420F-B822-D59965244E51}.Release|Any CPU.Build.0 = Release|Any CPU + {EA8DA8CF-9DE4-4815-93C3-ED92A6B5E64D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA8DA8CF-9DE4-4815-93C3-ED92A6B5E64D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA8DA8CF-9DE4-4815-93C3-ED92A6B5E64D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA8DA8CF-9DE4-4815-93C3-ED92A6B5E64D}.Release|Any CPU.Build.0 = Release|Any CPU + {DB82BD88-23C1-4E8A-9E48-92848E1942C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB82BD88-23C1-4E8A-9E48-92848E1942C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB82BD88-23C1-4E8A-9E48-92848E1942C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB82BD88-23C1-4E8A-9E48-92848E1942C0}.Release|Any CPU.Build.0 = Release|Any CPU + {03D41846-3257-4097-9145-21DDA9546833}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03D41846-3257-4097-9145-21DDA9546833}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03D41846-3257-4097-9145-21DDA9546833}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03D41846-3257-4097-9145-21DDA9546833}.Release|Any CPU.Build.0 = Release|Any CPU + {7E147EBC-5B8C-462F-B736-545167935CB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E147EBC-5B8C-462F-B736-545167935CB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E147EBC-5B8C-462F-B736-545167935CB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E147EBC-5B8C-462F-B736-545167935CB3}.Release|Any CPU.Build.0 = Release|Any CPU + {AEF4D71A-C334-4E13-AECD-8EEA06872BB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AEF4D71A-C334-4E13-AECD-8EEA06872BB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AEF4D71A-C334-4E13-AECD-8EEA06872BB0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AEF4D71A-C334-4E13-AECD-8EEA06872BB0}.Release|Any CPU.Build.0 = Release|Any CPU + {E52DDB90-5F33-4189-8D72-81DE8165ADE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E52DDB90-5F33-4189-8D72-81DE8165ADE6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E52DDB90-5F33-4189-8D72-81DE8165ADE6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E52DDB90-5F33-4189-8D72-81DE8165ADE6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {29DC3FA3-643B-4E92-B176-C6594960AD4B} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + {1E4DFB97-CD50-4836-8B62-46D6FBDF924C} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + {90AD4721-1C34-4B9A-9515-6AFC095F55E8} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + {B19801B5-7C84-4669-92AF-7581ABBDE259} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + {105164C0-685F-420F-B822-D59965244E51} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + {EA8DA8CF-9DE4-4815-93C3-ED92A6B5E64D} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + {03D41846-3257-4097-9145-21DDA9546833} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + {7E147EBC-5B8C-462F-B736-545167935CB3} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + {E52DDB90-5F33-4189-8D72-81DE8165ADE6} = {20087506-E20F-4FD6-ADFB-9D7ADBBD3998} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4C661325-1729-4560-89F9-E2B51B111B72} + EndGlobalSection +EndGlobal diff --git a/Common.Types/Common.Types.csproj b/Common.Types/Common.Types.csproj new file mode 100644 index 0000000..d713fed --- /dev/null +++ b/Common.Types/Common.Types.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + enable + enable + $(MSBuildProjectName.Replace(" ", "_")) + + + + + + + + + + + diff --git a/Common.Types/Continents/Countries/ContinentCountry.cs b/Common.Types/Continents/Countries/ContinentCountry.cs new file mode 100644 index 0000000..ba68d74 --- /dev/null +++ b/Common.Types/Continents/Countries/ContinentCountry.cs @@ -0,0 +1,16 @@ +using Common.Types.Continent.Countries; +using Connected.Annotations; +using Connected.Entities; +using Connected.Entities.Annotations; + +namespace Common.Types.Continents.Countries; + +[Table(Schema = SchemaAttribute.TypesSchema)] +internal record ContinentCountry : Entity, IContinentCountry +{ + [Ordinal(0)] + public int Continent { get; init; } + + [Ordinal(1)] + public int Country { get; init; } +} diff --git a/Common.Types/Continents/Countries/ContinentCountryCache.cs b/Common.Types/Continents/Countries/ContinentCountryCache.cs new file mode 100644 index 0000000..7519030 --- /dev/null +++ b/Common.Types/Continents/Countries/ContinentCountryCache.cs @@ -0,0 +1,11 @@ +using Connected.Entities.Caching; + +namespace Common.Types.Continents.Countries; + +internal interface IContinentCountryCache : IEntityCacheClient { } +internal class ContinentCountryCache : EntityCacheClient, IContinentCountryCache +{ + public ContinentCountryCache(IEntityCacheContext context) : base(context, "_") + { + } +} diff --git a/Common.Types/Continents/Countries/ContinentCountryService.cs b/Common.Types/Continents/Countries/ContinentCountryService.cs new file mode 100644 index 0000000..eb8002f --- /dev/null +++ b/Common.Types/Continents/Countries/ContinentCountryService.cs @@ -0,0 +1,38 @@ +using Common.Types.Continent.Countries; +using Connected.ServiceModel; +using Connected.Services; +using System.Collections.Immutable; + +namespace Common.Types.Continents.Countries; + +internal class ContinentCountryService : EntityService, IContinentCountryService +{ + public ContinentCountryService(IContext context) : base(context) + { + } + + public Task Delete(PrimaryKeyArgs args) + { + throw new NotImplementedException(); + } + + public Task Insert(InsertContinentCountryArgs args) + { + throw new NotImplementedException(); + } + + public Task?> Query() + { + throw new NotImplementedException(); + } + + public Task?> QueryCountries(PrimaryKeyArgs args) + { + throw new NotImplementedException(); + } + + public Task SelectCountry(PrimaryKeyArgs args) + { + throw new NotImplementedException(); + } +} diff --git a/Common.Types/Continents/Countries/ContinentProtector.cs b/Common.Types/Continents/Countries/ContinentProtector.cs new file mode 100644 index 0000000..5aa9e7b --- /dev/null +++ b/Common.Types/Continents/Countries/ContinentProtector.cs @@ -0,0 +1,25 @@ +using Common.Types.Continent; +using Common.Types.Countries; +using Connected.Data.DataProtection; +using Connected.Data.EntityProtection; +using Connected.Entities; +using Connected.Middleware; +using Connected.Validation; + +namespace Common.Types.Continents.Countries; + +internal class ContinentProtector : MiddlewareComponent, IEntityProtector +{ + public ContinentProtector(IContinentCountryCache cache) + { + Cache = cache; + } + + private IContinentCountryCache Cache { get; } + + public async Task Invoke(EntityProtectionArgs args) + { + if ((await (from dc in Cache where dc.Continent == args.Entity.Id select dc).AsEntity()) is not null) + throw ValidationExceptions.ReferenceExists(typeof(ICountry), args.Entity.Id); + } +} diff --git a/Common.Types/Countries/Country.cs b/Common.Types/Countries/Country.cs new file mode 100644 index 0000000..60f9c55 --- /dev/null +++ b/Common.Types/Countries/Country.cs @@ -0,0 +1,22 @@ +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; + +namespace Common.Types.Countries; + +[Table(Schema = SchemaAttribute.TypesSchema)] +internal record Country : ConsistentEntity, ICountry +{ + public const string CacheKey = $"{SchemaAttribute.TypesSchema}.{nameof(Country)}"; + + [Length(128)] + public string Name { get; init; } + + public int Lcid { get; init; } + + [Length(128)] + public string IsoCode { get; init; } + + + public Status Status { get; init; } = Status.Enabled; +} diff --git a/Common.Types/Countries/CountryCache.cs b/Common.Types/Countries/CountryCache.cs new file mode 100644 index 0000000..e9897b2 --- /dev/null +++ b/Common.Types/Countries/CountryCache.cs @@ -0,0 +1,14 @@ +using Connected.Entities.Caching; + +namespace Common.Types.Countries; + +internal interface ICountryCache : IEntityCacheClient { } +/// +/// Cache for the entity. +/// +internal sealed class CountryCache : EntityCacheClient, ICountryCache +{ + public CountryCache(IEntityCacheContext context) : base(context, Country.CacheKey) + { + } +} diff --git a/Common.Types/Countries/CountryOps.cs b/Common.Types/Countries/CountryOps.cs new file mode 100644 index 0000000..ed83991 --- /dev/null +++ b/Common.Types/Countries/CountryOps.cs @@ -0,0 +1,156 @@ +using System.Collections.Immutable; +using Connected; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Types.Countries; + +internal sealed class QueryCountries : ServiceFunction?> +{ + public QueryCountries(ICountryCache cache) + { + Cache = cache; + } + + private ICountryCache Cache { get; } + protected override async Task?> OnInvoke() + { + return await (from dc in Cache select dc).AsEntities(); + } +} + +internal sealed class SelectCountry : ServiceFunction, ICountry?> +{ + public SelectCountry(ICountryCache cache) + { + Cache = cache; + } + + private ICountryCache Cache { get; } + protected override async Task OnInvoke() + { + return await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + } +} + +internal sealed class SelectCountryByName : ServiceFunction +{ + public SelectCountryByName(ICountryCache cache) + { + Cache = cache; + } + + private ICountryCache Cache { get; } + protected override async Task OnInvoke() + { + return await (from dc in Cache where string.Equals(dc.Name, Arguments.Name, StringComparison.OrdinalIgnoreCase) select dc).AsEntity(); + } +} + +internal sealed class LookupCountries : ServiceFunction, ImmutableList?> +{ + public LookupCountries(ICountryCache cache) + { + Cache = cache; + } + + private ICountryCache Cache { get; } + protected override async Task?> OnInvoke() + { + if (Arguments.IdList is null) + return null; + + return await (from dc in Cache where Arguments.IdList.Contains(dc.Id) select dc).AsEntities(); + } +} +internal sealed class DeleteCountry : ServiceAction> +{ + public DeleteCountry(ICountryService countryService, IStorageProvider storage, ICountryCache cache, IEventService events) + { + CountryService = countryService; + Storage = storage; + Cache = cache; + Events = events; + } + + private ICountryService CountryService { get; } + private IStorageProvider Storage { get; } + private ICountryCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(new Country { Id = Arguments.Id, State = State.Deleted }); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Arguments.Id); + await Events.Enqueue(this, CountryService, ServiceEvents.Deleted, Arguments.Id); + } +} + +internal sealed class InsertCountry : ServiceFunction +{ + public InsertCountry(ICountryService countryService, IStorageProvider database, IEventService events, ICountryCache cache) + { + CountryService = countryService; + Database = database; + Events = events; + Cache = cache; + } + private ICountryService CountryService { get; } + private IStorageProvider Database { get; } + private IEventService Events { get; } + private ICountryCache Cache { get; } + + protected override async Task OnInvoke() + { + var entity = Arguments.AsEntity(State.New); + var result = await Database.Open().Update(entity); + + return result is null ? 0 : result.Id; + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Result); + await Events.Enqueue(this, CountryService, ServiceEvents.Inserted, Result); + } +} + +internal sealed class UpdateCountry : ServiceAction +{ + public UpdateCountry(ICountryService countryService, IStorageProvider database, ICountryCache cache, IEventService events) + { + CountryService = countryService; + Database = database; + Cache = cache; + Events = events; + } + + private IStorageProvider Database { get; } + private ICountryCache Cache { get; } + private IEventService Events { get; } + private ICountryService CountryService { get; } + + protected override async Task OnInvoke() + { + await Database.Open().Update(await Load(), Arguments, async () => + { + await Cache.Refresh(Arguments.Id); + + return await Load(); + }); + } + private async Task Load() => await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + + protected override async Task OnCommitted() + { + await Cache.Refresh(Arguments.Id); + await Events.Enqueue(this, CountryService, ServiceEvents.Updated, Arguments.Id); + } +} diff --git a/Common.Types/Countries/CountryService.cs b/Common.Types/Countries/CountryService.cs new file mode 100644 index 0000000..a6ac653 --- /dev/null +++ b/Common.Types/Countries/CountryService.cs @@ -0,0 +1,56 @@ +using Common.Types.Security; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Services.Annotations; +using System.Collections.Immutable; + +namespace Common.Types.Countries; + +internal sealed class CountryService : EntityService, ICountryService +{ + public CountryService(IContext context) : base(context) + { + } + + [ServiceAuthorization(Claims.Delete)] + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(Claims.Add)] + public async Task Insert(InsertCountryArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(Claims.Read)] + public async Task?> Query() + { + return await Invoke(GetOperation(), Dto.Empty); + } + + [ServiceAuthorization(Claims.Read)] + public async Task?> Query(PrimaryKeyListArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(Claims.Read)] + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(Claims.Read)] + public async Task Select(NameArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(Claims.Modify)] + public async Task Update(UpdateCountryArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Common.Types/Countries/CountryValidator.cs b/Common.Types/Countries/CountryValidator.cs new file mode 100644 index 0000000..8e905d6 --- /dev/null +++ b/Common.Types/Countries/CountryValidator.cs @@ -0,0 +1,53 @@ +using Connected.Middleware; +using Connected.Validation; + +namespace Common.Types.Countries; + +internal class CountryValidator : MiddlewareComponent, IValidator +{ + public CountryValidator(ICountryService countryService) + { + CountryService = countryService; + } + + private ICountryService CountryService { get; } + /// + /// + /// + /// + /// + public async Task Validate(InsertCountryArgs args) + { + /* + * Country has the following constraints: + * - name is unique + * - lcid is unique + * - iso2Alpha code is unique + */ + + /* + * This method handles insert and update country methods. + * The algorithm is different for each method. + * First check if it's an update + */ + var update = args as UpdateCountryArgs; + + foreach (var country in await CountryService.Query()) + { + /* + * Ignore self record. + */ + if (update is not null && country.Id == update.Id) + continue; + + if (string.Equals(country.Name, args.Name, StringComparison.OrdinalIgnoreCase)) + throw ValidationExceptions.ValueExists(nameof(country.Name), args.Name); + + if (country.Lcid > 0 && country.Lcid == args.Lcid) + throw ValidationExceptions.ValueExists(nameof(country.Lcid), args.Lcid); + + if (!string.IsNullOrEmpty(country.IsoCode) && string.Equals(country.IsoCode, args.IsoCode, StringComparison.OrdinalIgnoreCase)) + throw ValidationExceptions.ValueExists(nameof(country.IsoCode), args.IsoCode); + } + } +} diff --git a/Common.Types/Currencies/DefaultCurrencyFormatterMiddleware.cs b/Common.Types/Currencies/DefaultCurrencyFormatterMiddleware.cs new file mode 100644 index 0000000..012f4a0 --- /dev/null +++ b/Common.Types/Currencies/DefaultCurrencyFormatterMiddleware.cs @@ -0,0 +1,25 @@ +using Common.Types.Middleware.Currencies; +using Connected.Interop; +using Connected.Middleware; +using System.Globalization; + +namespace Common.Types.Currencies; + +internal class DefaultCurrencyFormatterMiddleware : MiddlewareComponent, ICurrencyFormatterMiddleware +{ + public Task Format(TValue value, ICurrency currency, string format) + { + if (value is null) + return Task.FromResult(null); + + if (string.IsNullOrWhiteSpace(format)) + format = "N2"; + + var culture = currency.Lcid == 0 ? CultureInfo.CurrentUICulture : CultureInfo.GetCultureInfo(currency.Lcid); + + if (!TypeConversion.TryConvert(value, out double converted, culture)) + return Task.FromResult(value.ToString()); + + return Task.FromResult(converted.ToString(format, culture)); + } +} diff --git a/Common.Types/Security/Claims.cs b/Common.Types/Security/Claims.cs new file mode 100644 index 0000000..23dbe0e --- /dev/null +++ b/Common.Types/Security/Claims.cs @@ -0,0 +1,10 @@ +namespace Common.Types.Security +{ + internal static class Claims + { + public const string Delete = "Common Delete"; + public const string Read = "Common Read"; + public const string Add = "Common Add"; + public const string Modify = "Common Modify"; + } +} diff --git a/Common.Types/ServerStartup.cs b/Common.Types/ServerStartup.cs new file mode 100644 index 0000000..7f0683d --- /dev/null +++ b/Common.Types/ServerStartup.cs @@ -0,0 +1,14 @@ +using System.Runtime.CompilerServices; + +using Connected; +using Connected.Annotations; + +[assembly: MicroService(MicroServiceType.Service)] +[assembly: InternalsVisibleTo("Common.Types.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] + +namespace Common.Types; + +internal class ServerStartup : Startup +{ +} diff --git a/Common.Types/TaxRates/TaxRate.cs b/Common.Types/TaxRates/TaxRate.cs new file mode 100644 index 0000000..94fa67b --- /dev/null +++ b/Common.Types/TaxRates/TaxRate.cs @@ -0,0 +1,33 @@ +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Concurrency; + +namespace Common.Types.TaxRates; + +/// +/// The entity. +/// +[Table(Schema = SchemaAttribute.TypesSchema)] +internal sealed record TaxRate : ConcurrentEntity, ITaxRate +{ + /// + /// The cache key under which all TaxRate entities are stored. + /// + public const string CacheKey = $"{SchemaAttribute.TypesSchema}.{nameof(TaxRate)}"; + /// + /// The name of the . + /// + [Length(128)] + public string? Name { get; init; } + /// + /// The rate of the . + /// + public float Rate { get; init; } + /// + /// The status. Only entities + /// should be used on front ends. + /// + [Default(Status.Enabled)] + public Status Status { get; init; } +} + diff --git a/Common.Types/TaxRates/TaxRateCache.cs b/Common.Types/TaxRates/TaxRateCache.cs new file mode 100644 index 0000000..a36d2d9 --- /dev/null +++ b/Common.Types/TaxRates/TaxRateCache.cs @@ -0,0 +1,26 @@ +using Connected.Entities.Caching; + +namespace Common.Types.TaxRates; + +/// +/// This is the contract for the typed cache. Using contracts for +/// caching is useful when writing unit tests so a developer +/// can mock the entire cache. +/// +internal interface ITaxRateCache : IEntityCacheClient { } +/// +/// Cache for the entity. +/// +/// +/// stores the entire set +/// of records in the database. This is useful for data which doesn't change +/// frequently because invalidating it ofter can have performance penalties. +/// The platform knows how to load and invalidate the entity so developer +/// doesn't nedd to write any code at all. +/// +internal sealed class TaxRateCache : EntityCacheClient, ITaxRateCache +{ + public TaxRateCache(IEntityCacheContext context) : base(context, TaxRate.CacheKey) + { + } +} diff --git a/Common.Types/TaxRates/TaxRateOps.cs b/Common.Types/TaxRates/TaxRateOps.cs new file mode 100644 index 0000000..8ed5c24 --- /dev/null +++ b/Common.Types/TaxRates/TaxRateOps.cs @@ -0,0 +1,252 @@ +using System.Collections.Immutable; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Types.TaxRates; + +internal class TaxRateOps +{ + /// + /// Queries all records. + /// + internal sealed class Query : ServiceFunction?> + { + /// + /// Creates a new instance of the class. + /// + /// The cache where the records are stored. + public Query(ITaxRateCache cache, IStorageProvider storage) + { + Cache = cache; + Storage = storage; + } + + private ITaxRateCache Cache { get; } + public IStorageProvider Storage { get; } + + protected override async Task?> OnInvoke() + { + var r = await (from dc in Storage.Open() select dc).WithArguments(Arguments).AsEntities(); + /* + * We simply return all records that exist in the system. + */ + return await (from dc in Cache select dc).WithArguments(Arguments).AsEntities(); + } + } + + /// + /// Returns with the specified id or null + /// if the record for the specified id does not exist. + /// + internal sealed class Select : ServiceFunction, ITaxRate?> + { + public Select(ITaxRateCache cache) + { + Cache = cache; + } + + private ITaxRateCache Cache { get; } + + protected override async Task OnInvoke() + { + /* + * Filter cache by the id. + */ + + return await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + } + } + /// + /// Returns with the specified name and rate or null if + /// the record with the specified arguments does not exist. + /// + internal sealed class SelectByRate : ServiceFunction + { + public SelectByRate(ITaxRateCache cache) + { + Cache = cache; + } + + private ITaxRateCache Cache { get; } + protected override async Task OnInvoke() + { + return await (from dc in Cache + where string.Equals(dc.Name, Arguments.Name, StringComparison.OrdinalIgnoreCase) && dc.Rate == Arguments.Rate + select dc).AsEntity(); + } + } + /// + /// Returns List of for the specified set of ids. Use this method when joining data + /// to other entities. + /// + internal sealed class Lookup : ServiceFunction, ImmutableList?> + { + public Lookup(ITaxRateCache cache) + { + Cache = cache; + } + + private ITaxRateCache Cache { get; } + protected override async Task?> OnInvoke() + { + /* + * Use simple linq join for the specified set of ids. + */ + return await (from dc in Cache where Arguments.IdList.Contains(dc.Id) select dc).AsEntities(); + } + } + /// + /// Inserts a new and returns its Id. + /// + internal sealed class Insert : ServiceFunction + { + public Insert(ITaxRateService taxRateService, IStorageProvider storage, IEventService events, ITaxRateCache cache) + { + TaxRateService = taxRateService; + Storage = storage; + Events = events; + Cache = cache; + } + private ITaxRateService TaxRateService { get; } + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private ITaxRateCache Cache { get; } + + protected override async Task OnInvoke() + { + /* + * First, create a new entity from the passed arguments and mark its state as new. This will + * signal the DatabaseContext to perform an insert operation when calling the Update. + */ + var entity = Arguments.AsEntity(State.New); + /* + * Call update on the DatabaseContext. This call will return a new ITaxRate of the inserted + * entity. + */ + return (await Storage.Open().Update(entity)).Id; + } + + protected override async Task OnCommitted() + { + /* + * At this stage, the transaction has been commited which means + * record is definitely permanently stored in the database. + * We are making a simple call to the cache to refresh the item with the + * new id. Cache will query a database for new record and will store it + * in memory. + */ + await Cache.Refresh(Result); + /* + * Now trigger the inserted event which will notify all components hooked in the + * same process, out of process scale out instances and clients (wasm). + */ + await Events.Enqueue(this, TaxRateService, ServiceEvents.Inserted, Result); + } + } + /// + /// Permanently deletes a from the system. + /// + /// + /// This method gets called after all + /// middleware passed successfuly. + /// + internal sealed class Delete : ServiceAction> + { + public Delete(ITaxRateService taxRateService, IStorageProvider storage, ITaxRateCache cache, IEventService events) + { + TaxRateService = taxRateService; + Storage = storage; + Cache = cache; + Events = events; + } + + private ITaxRateService TaxRateService { get; } + private IStorageProvider Storage { get; } + private ITaxRateCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + /* + * we don't need a reference to a record here because delete uses only + * an id. The overhead would only occur in cases if the record wouldn't exist + * but this is not the job of the operation. The Validators should theoretically + * reject such an operation. + */ + await Storage.Open().Update(new TaxRate { Id = Arguments.Id, State = State.Deleted }); + } + + protected override async Task OnCommitted() + { + /* + * Once all the transactions have been commited remove the non existing record + * from the cache. + */ + await Cache.Remove(Arguments.Id); + /* + * And notify audience about the event. + */ + await Events.Enqueue(this, TaxRateService, ServiceEvents.Deleted, Arguments.Id); + } + } + /// + /// Updates the entity. + /// + internal sealed class Update : ServiceAction + { + public Update(ITaxRateService taxRateService, IStorageProvider storage, ITaxRateCache cache, IEventService events) + { + TaxRateService = taxRateService; + Storage = storage; + Cache = cache; + Events = events; + } + + private IStorageProvider Storage { get; } + private ITaxRateCache Cache { get; } + private IEventService Events { get; } + private ITaxRateService TaxRateService { get; } + + protected override async Task OnInvoke() + { + /* + * TaxRate is concurrency entity which means the platform takes care of + * data consistency. Data consistency means we can't overwrite updates made + * by others. + * Updating Concurrency entity thus requires a bit more logic. We must use a retry logic + * in case of Concurrency failure. We'll call Update method with reload lambda function. + */ + var entity = SetState(await Load()); + + await Storage.Open().Update(entity, Arguments, async () => + { + /* + * The concurrency exception occured. + * Refresh the entry from the cache. This will load a new version from + * a database. + */ + await Cache.Refresh(Arguments.Id); + + return SetState(await Load()); + }); + } + + private async Task Load() => await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + + protected override async Task OnCommitted() + { + /* + * Once the update is complete refresh the cache because the database assigned a new timestamp + * to an entity and any sunsequent update would fail anyway. + */ + await Cache.Refresh(Arguments.Id); + /* + * Now trigger the distributed event notifying the update has completed. + */ + await Events.Enqueue(this, TaxRateService, ServiceEvents.Updated, Arguments.Id); + } + } +} \ No newline at end of file diff --git a/Common.Types/TaxRates/TaxRateService.cs b/Common.Types/TaxRates/TaxRateService.cs new file mode 100644 index 0000000..c920ed2 --- /dev/null +++ b/Common.Types/TaxRates/TaxRateService.cs @@ -0,0 +1,94 @@ +using Common.Types.Security; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Services.Annotations; +using System.Collections.Immutable; + +namespace Common.Types.TaxRates; + +/// +/// The implementation class for service. +/// +internal sealed class TaxRateService : EntityService, ITaxRateService +{ + public TaxRateService(IContext context) : base(context) + { + } + /// + /// Perorfms a lookup on records based on a specified + /// set of ids. + /// + /// The list of ids for which records should be returned. + /// The list if records. + [ServiceAuthorization(Claims.Read)] + public async Task?> Query(PrimaryKeyListArgs e) + { + return await Invoke(GetOperation(), e); + } + /// + /// Queries the entire entity set. + /// + /// The list of all entities. + [ServiceAuthorization(Claims.Read)] + public async Task?> Query(QueryArgs? args) + { + return await Invoke(GetOperation(), args ?? QueryArgs.NoPaging); + } + /// + /// Searches for the first entity which matches + /// the specified arguments. + /// + /// The arguments representing search criteria. + /// The first entity which matches the + /// criteria, null otherwise. + [ServiceAuthorization(Claims.Read)] + public async Task Select(TaxRateArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + /// Selects the for the specified id. + /// + /// The id for which a record will + /// be returned. + /// The entity with the specified id, + /// null otherwise. + [ServiceAuthorization(Claims.Read)] + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + /// Permanently deletes an entity with + /// the specified id. + /// + /// + /// This operation can be rejected by an + /// middleware. + /// + /// The id of the entity which will be deleted. + [ServiceAuthorization(Claims.Delete)] + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + /// + /// Inserts a new entity into the system. + /// + /// The values representing a new entity. + /// An id of the newly inserted entity. + [ServiceAuthorization(Claims.Add)] + public async Task Insert(InsertTaxRateArgs args) + { + return await Invoke(GetOperation(), args); + } + /// + /// Updates an existing entity. + /// + /// The arguments representing a changed values. + [ServiceAuthorization(Claims.Modify)] + public async Task Update(UpdateTaxRateArgs args) + { + await Invoke(GetOperation(), args); + } +} \ No newline at end of file diff --git a/Common.Types/TaxRates/TaxRatesValidators.cs b/Common.Types/TaxRates/TaxRatesValidators.cs new file mode 100644 index 0000000..85599bb --- /dev/null +++ b/Common.Types/TaxRates/TaxRatesValidators.cs @@ -0,0 +1,48 @@ +using Connected.Entities; +using Connected.Middleware; +using Connected.Validation; + +namespace Common.Types.TaxRates; + +/// +/// Business logic validator for the arguments. +/// +internal sealed class InsertTaxRateValidator : MiddlewareComponent, IValidator +{ + public InsertTaxRateValidator(ITaxRateCache cache) + { + Service = cache; + } + + private ITaxRateCache Service { get; } + + public async Task Validate(InsertTaxRateArgs args) + { + /* + * We have a constraint on a name property. + */ + if (await (from dc in Service where string.Equals(dc.Name, args.Name, StringComparison.OrdinalIgnoreCase) select dc).AsEntity() is ITaxRate existing) + throw ValidationExceptions.ValueExists(nameof(args.Name), args.Name); + } +} +/// +/// Business logic validator for the arguments. +/// +internal sealed class UpdateTaxRateValidator : MiddlewareComponent, IValidator +{ + public UpdateTaxRateValidator(ITaxRateCache cache) + { + Service = cache; + } + + private ITaxRateCache Service { get; } + + public async Task Validate(UpdateTaxRateArgs args) + { + /* + * A difference from the insert validator is to exclude the id of the updating record. + */ + if (await (from dc in Service where string.Equals(dc.Name, args.Name, StringComparison.OrdinalIgnoreCase) && dc.Id != args.Id select dc).AsEntity() is ITaxRate existing) + throw ValidationExceptions.ValueExists(nameof(args.Name), args.Name); + } +}