From 7f651893426344da9027d3849338e962dd568424 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:58:37 +0100 Subject: [PATCH 01/46] add Contact entity (#2) --- PhoneForge.sln | 14 +++ src/PhoneForge.Domain/Contacts/Contact.cs | 89 +++++++++++++++ .../Contacts/ContactErrors.cs | 61 ++++++++++ src/PhoneForge.Domain/Contacts/Email.cs | 58 ++++++++++ src/PhoneForge.Domain/Contacts/FirstName.cs | 53 +++++++++ src/PhoneForge.Domain/Contacts/LastName.cs | 53 +++++++++ src/PhoneForge.Domain/Contacts/PhoneNumber.cs | 68 ++++++++++++ .../PhoneForge.Domain.csproj | 3 + .../PhoneForge.WebApi.csproj | 1 - src/SharedKernel/Ensure.cs | 69 ++++++++++++ src/SharedKernel/Entity.cs | 35 ++++++ src/SharedKernel/Error.cs | 84 ++++++++++++++ src/SharedKernel/ErrorType.cs | 29 +++++ src/SharedKernel/IAuditableEntity.cs | 17 +++ src/SharedKernel/ISoftDeletableEntity.cs | 17 +++ src/SharedKernel/Result.cs | 104 ++++++++++++++++++ src/SharedKernel/ResultT.cs | 40 +++++++ src/SharedKernel/SharedKernel.csproj | 7 ++ 18 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 src/PhoneForge.Domain/Contacts/Contact.cs create mode 100644 src/PhoneForge.Domain/Contacts/ContactErrors.cs create mode 100644 src/PhoneForge.Domain/Contacts/Email.cs create mode 100644 src/PhoneForge.Domain/Contacts/FirstName.cs create mode 100644 src/PhoneForge.Domain/Contacts/LastName.cs create mode 100644 src/PhoneForge.Domain/Contacts/PhoneNumber.cs create mode 100644 src/SharedKernel/Ensure.cs create mode 100644 src/SharedKernel/Entity.cs create mode 100644 src/SharedKernel/Error.cs create mode 100644 src/SharedKernel/ErrorType.cs create mode 100644 src/SharedKernel/IAuditableEntity.cs create mode 100644 src/SharedKernel/ISoftDeletableEntity.cs create mode 100644 src/SharedKernel/Result.cs create mode 100644 src/SharedKernel/ResultT.cs create mode 100644 src/SharedKernel/SharedKernel.csproj diff --git a/PhoneForge.sln b/PhoneForge.sln index 1dfe029..67169f9 100644 --- a/PhoneForge.sln +++ b/PhoneForge.sln @@ -21,6 +21,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.UnitTests", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.Console", "src\PhoneForge.Console\PhoneForge.Console.csproj", "{44808F61-EF0E-4854-90A7-2554235FDAD9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedKernel", "src\SharedKernel\SharedKernel.csproj", "{B9C5B96B-5763-47D3-ADDE-F840127A53C4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{089100B1-113F-4E66-888A-E83F3999EAFD}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + Directory.Build.Props = Directory.Build.Props + Directory.Packages.props = Directory.Packages.props + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +64,10 @@ Global {44808F61-EF0E-4854-90A7-2554235FDAD9}.Debug|Any CPU.Build.0 = Debug|Any CPU {44808F61-EF0E-4854-90A7-2554235FDAD9}.Release|Any CPU.ActiveCfg = Release|Any CPU {44808F61-EF0E-4854-90A7-2554235FDAD9}.Release|Any CPU.Build.0 = Release|Any CPU + {B9C5B96B-5763-47D3-ADDE-F840127A53C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9C5B96B-5763-47D3-ADDE-F840127A53C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9C5B96B-5763-47D3-ADDE-F840127A53C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9C5B96B-5763-47D3-ADDE-F840127A53C4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -67,5 +80,6 @@ Global {6FCDD768-EB98-42DA-B5CA-D92072E6AD66} = {B28046D9-09AF-4324-BACA-938288D63BB8} {18E039F8-BFDD-4DC2-B973-2F90371D3939} = {9BA151AB-1A46-4852-9403-A041A84D4CC7} {44808F61-EF0E-4854-90A7-2554235FDAD9} = {B28046D9-09AF-4324-BACA-938288D63BB8} + {B9C5B96B-5763-47D3-ADDE-F840127A53C4} = {B28046D9-09AF-4324-BACA-938288D63BB8} EndGlobalSection EndGlobal diff --git a/src/PhoneForge.Domain/Contacts/Contact.cs b/src/PhoneForge.Domain/Contacts/Contact.cs new file mode 100644 index 0000000..13dad08 --- /dev/null +++ b/src/PhoneForge.Domain/Contacts/Contact.cs @@ -0,0 +1,89 @@ +using SharedKernel; + +namespace PhoneForge.Domain.Contacts; + +/// +/// Represents the user entity. +/// +public sealed class Contact : Entity, ISoftDeletableEntity, IAuditableEntity +{ + /// + /// Initializes a new instance of the class. + /// + /// The contact first name. + /// The contact last name. + /// The contact email. + /// The contact phone number. + private Contact(FirstName firstName, LastName lastName, Email email, PhoneNumber phoneNumber) + : base(Guid.NewGuid()) + { + Ensure.NotNull(firstName, "The first name is required", nameof(firstName)); + Ensure.NotNull(lastName, "The last name is required", nameof(lastName)); + Ensure.NotNull(email, "The email is required", nameof(email)); + Ensure.NotNull(phoneNumber, "The phone number is required", nameof(phoneNumber)); + + FirstName = firstName; + LastName = lastName; + Email = email; + PhoneNumber = phoneNumber; + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// Required by EF Core. + /// + private Contact() { } + + /// + /// Gets or sets the contact first name. + /// + public FirstName? FirstName { get; private set; } + + /// + /// Gets or sets the contact last name. + /// + public LastName? LastName { get; private set; } + + /// + /// Gets the contact full name. + /// + public string FullName => $"{FirstName} {LastName}"; + + /// + /// Gets or sets the contact email. + /// + public Email? Email { get; private set; } + + /// + /// Gets or sets the contact phone number. + /// + public PhoneNumber? PhoneNumber { get; private set; } + + /// + public DateTime? DeletedOnUtc { get; } + + /// + public bool Deleted { get; } + + /// + public DateTime CreatedOnUtc { get; } + + /// + public DateTime? ModifiedOnUtc { get; } + + /// + /// Creates a new contact with the specified first name, last name, email and phone number. + /// + /// The first name. + /// The last name. + /// The email. + /// The phone number. + /// The newly created contact instance. + public static Contact Create(FirstName firstName, LastName lastName, Email email, PhoneNumber phoneNumber) + { + var contact = new Contact(firstName, lastName, email, phoneNumber); + return contact; + } +} diff --git a/src/PhoneForge.Domain/Contacts/ContactErrors.cs b/src/PhoneForge.Domain/Contacts/ContactErrors.cs new file mode 100644 index 0000000..6cfa1b0 --- /dev/null +++ b/src/PhoneForge.Domain/Contacts/ContactErrors.cs @@ -0,0 +1,61 @@ +using SharedKernel; + +namespace PhoneForge.Domain.Contacts; + +/// +/// Contains the contact errors. +/// +public static class ContactErrors +{ + /// + /// Contains the first name errors. + /// + public static class FirstName + { + public static Error NullOrEmpty => Error.Validation("FirstName.NullOrEmpty", "The first name is required."); + + public static Error LongerThanAllowed => + Error.Validation("FirstName.LongerThanAllowed", "The first name is longer than allowed"); + } + + /// + /// Contains the last name errors. + /// + public static class LastName + { + public static Error NullOrEmpty => Error.Validation("LastName.NullOrEmpty", "The last name is required."); + + public static Error LongerThanAllowed => + Error.Validation("LastName.LongerThanAllowed", "The last name is longer than allowed."); + } + + /// + /// Contains the email errors. + /// + public static class Email + { + public static Error NullOrEmpty => Error.Validation("Email.NullOrEmpty", "The email is required."); + + public static Error LongerThanAllowed => + Error.Validation("Email.LongerThanAllowed", "The email is longer than allowed."); + + public static Error InvalidFormat => Error.Validation("Email.InvalidFormat", "The email format is invalid."); + } + + /// + /// Contains the phone number errors. + /// + public static class PhoneNumber + { + public static Error NullOrEmpty => Error.Validation("PhoneNumber.NullOrEmpty", "The phone number is required."); + + public static Error LongerThanAllowed => + Error.Validation("PhoneNumber.LongerThanAllowed", "The phone number is longer than allowed"); + + public static Error ShorterThanAllowed => + Error.Validation("PhoneNumber.ShorterThanAllowed", "The phone number is shorter than allowed."); + + public static Error InvalidFormat => + Error.Validation("PhoneNumber.InvalidFormat", "The phone number format is invalid."); + } +} diff --git a/src/PhoneForge.Domain/Contacts/Email.cs b/src/PhoneForge.Domain/Contacts/Email.cs new file mode 100644 index 0000000..8869991 --- /dev/null +++ b/src/PhoneForge.Domain/Contacts/Email.cs @@ -0,0 +1,58 @@ +using SharedKernel; + +namespace PhoneForge.Domain.Contacts; + +/// +/// Represents the email value object. +/// +public sealed record Email +{ + /// + /// The email maximum length. + /// + public const int MaxLength = 100; + + /// + /// Initializes a new instance of the class. + /// + /// The email value. + private Email(string value) + { + Value = value; + } + + /// + /// Gets the email value. + /// + public string Value { get; } + + public static implicit operator string(Email email) + { + return email.Value; + } + + /// + /// Creates a new instance based on the specified value. + /// + /// The email value. + /// The result of the email creation process containing the email or an error. + public static Result Create(string? email) + { + if (string.IsNullOrWhiteSpace(email)) + { + return ContactErrors.Email.NullOrEmpty; + } + + if (email.Length > MaxLength) + { + return ContactErrors.Email.LongerThanAllowed; + } + + if (!email.Contains('@')) + { + return ContactErrors.Email.InvalidFormat; + } + + return new Email(email); + } +} diff --git a/src/PhoneForge.Domain/Contacts/FirstName.cs b/src/PhoneForge.Domain/Contacts/FirstName.cs new file mode 100644 index 0000000..84e0f64 --- /dev/null +++ b/src/PhoneForge.Domain/Contacts/FirstName.cs @@ -0,0 +1,53 @@ +using SharedKernel; + +namespace PhoneForge.Domain.Contacts; + +/// +/// Represents the first name value object. +/// +public sealed record FirstName +{ + /// + /// The first name maximum length. + /// + public const int MaxLength = 100; + + /// + /// Initializes a new instance of the class. + /// + /// The first name value. + private FirstName(string value) + { + Value = value; + } + + /// + /// Gets the first name value. + /// + public string Value { get; } + + public static implicit operator string(FirstName firstName) + { + return firstName.Value; + } + + /// + /// Creates a new instance based on the specified value. + /// + /// The first name value. + /// The result of the first name creation process containing the first name or an error. + public static Result Create(string? firstName) + { + if (string.IsNullOrWhiteSpace(firstName)) + { + return ContactErrors.FirstName.NullOrEmpty; + } + + if (firstName.Length > MaxLength) + { + return ContactErrors.FirstName.LongerThanAllowed; + } + + return new FirstName(firstName); + } +} diff --git a/src/PhoneForge.Domain/Contacts/LastName.cs b/src/PhoneForge.Domain/Contacts/LastName.cs new file mode 100644 index 0000000..a50e6b2 --- /dev/null +++ b/src/PhoneForge.Domain/Contacts/LastName.cs @@ -0,0 +1,53 @@ +using SharedKernel; + +namespace PhoneForge.Domain.Contacts; + +/// +/// Represents the last name value object. +/// +public sealed record LastName +{ + /// + /// The last name maximum length. + /// + public const int MaxLength = 100; + + /// + ///Initializes a new instance of the class. + /// + /// The last name value. + private LastName(string value) + { + Value = value; + } + + /// + /// Gets the last name value. + /// + public string Value { get; } + + public static implicit operator string(LastName lastName) + { + return lastName.Value; + } + + /// + /// Creates a new instance based on the specified value. + /// + /// The last name value. + /// The result of the last name creation process containing the last name or an error. + public static Result Create(string? lastName) + { + if (string.IsNullOrWhiteSpace(lastName)) + { + return ContactErrors.LastName.NullOrEmpty; + } + + if (lastName.Length > MaxLength) + { + return ContactErrors.LastName.LongerThanAllowed; + } + + return new LastName(lastName); + } +} diff --git a/src/PhoneForge.Domain/Contacts/PhoneNumber.cs b/src/PhoneForge.Domain/Contacts/PhoneNumber.cs new file mode 100644 index 0000000..3848bdd --- /dev/null +++ b/src/PhoneForge.Domain/Contacts/PhoneNumber.cs @@ -0,0 +1,68 @@ +using SharedKernel; + +namespace PhoneForge.Domain.Contacts; + +/// +/// Represents the phone number value object. +/// +public sealed record PhoneNumber +{ + /// + /// The phone number maximum length. + /// + public const int MaxLength = 20; + + /// + /// The phone number minimum length. + /// + public const int MinLength = 6; + + /// + /// Initializes a new instance of the class. + /// + /// The phone number value. + private PhoneNumber(string value) + { + Value = value; + } + + /// + /// Gets the phone number value. + /// + public string Value { get; } + + public static implicit operator string(PhoneNumber phoneNumber) + { + return phoneNumber.Value; + } + + /// + /// Creates a new instance based on the specified value. + /// + /// The phone number value. + /// The result of the phone number creation process containing the phone number or an error. + public static Result Create(string? phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + return ContactErrors.PhoneNumber.NullOrEmpty; + } + + if (phoneNumber.Length > MaxLength) + { + return ContactErrors.PhoneNumber.LongerThanAllowed; + } + + if (phoneNumber.Length < MinLength) + { + return ContactErrors.PhoneNumber.ShorterThanAllowed; + } + + if (!phoneNumber.All(char.IsDigit)) + { + return ContactErrors.PhoneNumber.InvalidFormat; + } + + return new PhoneNumber(phoneNumber); + } +} diff --git a/src/PhoneForge.Domain/PhoneForge.Domain.csproj b/src/PhoneForge.Domain/PhoneForge.Domain.csproj index c0c3901..42d04d6 100644 --- a/src/PhoneForge.Domain/PhoneForge.Domain.csproj +++ b/src/PhoneForge.Domain/PhoneForge.Domain.csproj @@ -1,3 +1,6 @@  + + + diff --git a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj b/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj index 39b9eab..ed04919 100644 --- a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj +++ b/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj @@ -8,6 +8,5 @@ - diff --git a/src/SharedKernel/Ensure.cs b/src/SharedKernel/Ensure.cs new file mode 100644 index 0000000..c2257ef --- /dev/null +++ b/src/SharedKernel/Ensure.cs @@ -0,0 +1,69 @@ +namespace SharedKernel; + +/// +/// Contains assertions for the most common application checks. +/// +public static class Ensure +{ + /// + /// Ensures that the specified value is not empty. + /// + /// The value to check. + /// The message to show if the check fails. + /// The name of the argument being checked. + /// if the specified value is empty. + public static void NotEmpty(string value, string message, string argumentName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException(message, argumentName); + } + } + + /// + /// Ensures that the specified value is not empty. + /// + /// The value to check. + /// The message to show if the check fails. + /// The name of the argument being checked. + /// if the specified value is empty. + public static void NotEmpty(Guid value, string message, string argumentName) + { + if (value == Guid.Empty) + { + throw new ArgumentException(message, argumentName); + } + } + + /// + /// Ensures that the specified value is not empty. + /// + /// The value to check. + /// The message to show if the check fails. + /// The name of the argument being checked. + /// if the specified value is the default value for the type. + public static void NotEmpty(DateTime value, string message, string argumentName) + { + if (value == default) + { + throw new ArgumentException(message, argumentName); + } + } + + /// + /// Ensures that the specified value is not null. + /// + /// The value type. + /// The value to check. + /// The message to show if the check fails. + /// The name of the argument being checked. + /// if the specified value is null. + public static void NotNull(T value, string message, string argumentName) + where T : class + { + if (value is null) + { + throw new ArgumentNullException(argumentName, message); + } + } +} diff --git a/src/SharedKernel/Entity.cs b/src/SharedKernel/Entity.cs new file mode 100644 index 0000000..b871625 --- /dev/null +++ b/src/SharedKernel/Entity.cs @@ -0,0 +1,35 @@ +namespace SharedKernel; + +/// +/// Represents the base class that all entities derive from. +/// +public abstract class Entity +{ + /// + /// Initializes a new instance of the class. + /// + /// The entity identifier. + /// + protected Entity(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentException("The identifier is required.", nameof(id)); + } + + Id = id; + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// Required by EF Core. + /// + protected Entity() { } + + /// + /// Gets or sets the entity identifier. + /// + public Guid Id { get; private set; } +} diff --git a/src/SharedKernel/Error.cs b/src/SharedKernel/Error.cs new file mode 100644 index 0000000..950bcac --- /dev/null +++ b/src/SharedKernel/Error.cs @@ -0,0 +1,84 @@ +namespace SharedKernel; + +/// +/// Represents a concrete domain error. +/// +public sealed record Error +{ + /// + /// Gets the empty error instance. + /// + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.Failure); + + /// + /// Initializes a new instance of the class. + /// + /// The error code. + /// A human-readable description of the error. + /// The indicating the category of the error. + private Error(string code, string description, ErrorType type) + { + Code = code; + Description = description; + Type = type; + } + + /// + /// Gets the error code. + /// + public string Code { get; } + + /// + /// Gets the error description. + /// + public string Description { get; } + + /// + /// Gets the error type. + /// + public ErrorType Type { get; } + + /// + /// Creates a failure error with the specified code and description. + /// + /// The error code. + /// The error description. + /// A new instance with + public static Error Failure(string code, string description) + { + return new(code, description, ErrorType.Failure); + } + + /// + /// Creates a validation error with the specified code and description. + /// + /// The error code. + /// The error description. + /// A new instance with + public static Error Validation(string code, string description) + { + return new(code, description, ErrorType.Validation); + } + + /// + /// Creates a not-found error with the specified code and description. + /// + /// The error code. + /// The error description. + /// A new instance with + public static Error NotFound(string code, string description) + { + return new(code, description, ErrorType.NotFound); + } + + /// + /// Creates a conflict error with the specified code and description. + /// + /// The error code. + /// The error description. + /// A new instance with + public static Error Conflict(string code, string description) + { + return new(code, description, ErrorType.Conflict); + } +} diff --git a/src/SharedKernel/ErrorType.cs b/src/SharedKernel/ErrorType.cs new file mode 100644 index 0000000..51c327d --- /dev/null +++ b/src/SharedKernel/ErrorType.cs @@ -0,0 +1,29 @@ +namespace SharedKernel; + +/// +/// Specifies the category of an . +/// +public enum ErrorType +{ + /// + /// Represents a general failure error. + /// + Failure = 0, + + /// + /// Indicates that the error is related to validation of input or state. + /// + Validation = 1, + + /// + /// Indicates that a requested resource was not found. + /// + NotFound = 2, + + /// + /// Indicates that a conflict occurred, such as attempting to create + /// a resource that already exists or performing an operation that + /// violates business rules. + /// + Conflict = 3, +} diff --git a/src/SharedKernel/IAuditableEntity.cs b/src/SharedKernel/IAuditableEntity.cs new file mode 100644 index 0000000..177688a --- /dev/null +++ b/src/SharedKernel/IAuditableEntity.cs @@ -0,0 +1,17 @@ +namespace SharedKernel; + +/// +/// Represents the marker interface for auditable entities. +/// +public interface IAuditableEntity +{ + /// + /// Gets the created on date and time in UTC format. + /// + DateTime CreatedOnUtc { get; } + + /// + /// Gets the modified date and time in UTC format. + /// + DateTime? ModifiedOnUtc { get; } +} diff --git a/src/SharedKernel/ISoftDeletableEntity.cs b/src/SharedKernel/ISoftDeletableEntity.cs new file mode 100644 index 0000000..a71bf23 --- /dev/null +++ b/src/SharedKernel/ISoftDeletableEntity.cs @@ -0,0 +1,17 @@ +namespace SharedKernel; + +/// +/// Represents the marker interface for soft-deletable entities. +/// +public interface ISoftDeletableEntity +{ + /// + /// Gets the date and time in UTC format the entity was deleted on. + /// + DateTime? DeletedOnUtc { get; } + + /// + /// Gets the value indicating whether the entity has been deleted. + /// + bool Deleted { get; } +} diff --git a/src/SharedKernel/Result.cs b/src/SharedKernel/Result.cs new file mode 100644 index 0000000..486db1d --- /dev/null +++ b/src/SharedKernel/Result.cs @@ -0,0 +1,104 @@ +namespace SharedKernel; + +/// +/// Represents a result of some operation, with status information and possibly an error. +/// +public class Result +{ + /// + /// Initializes a new instance of the class with the specified parameters. + /// + /// The flag indicating if the result is successful. + /// The error. + public Result(bool isSuccess, Error error) + { + if (isSuccess && error != Error.None || !isSuccess && error == Error.None) + { + throw new ArgumentException("Invalid error", nameof(error)); + } + + IsSuccess = isSuccess; + Error = error; + } + + /// + /// Gets a value indicating whether the result is a success result. + /// + public bool IsSuccess { get; } + + /// + /// Gets a value indicating whether the result is a failure result. + /// + public bool IsFailure => !IsSuccess; + + /// + /// Gets the error. + /// + public Error Error { get; } + + /// + /// Returns a success . + /// + /// A new instance of with the success flag set. + public static Result Success() + { + return new Result(true, Error.None); + } + + /// + /// Returns a success with the specified value. + /// + /// The result type. + /// The result value. + /// A new instance of with the success flag set. + public static Result Success(TValue value) + { + return new Result(value, true, Error.None); + } + + /// + /// Returns a failure with the specified error. + /// + /// The error. + /// A new instance of with the specified error and failure flag set. + public static Result Failure(Error error) + { + return new Result(false, error); + } + + /// + /// Returns a failure with the specified error. + /// + /// The result type. + /// The error. + /// A new instance of with the specified error and failure flag set. + /// + /// We're purposefully ignoring the nullable assignment here because the API will never allow it to be accessed. + /// The value is accessed through a method that will throw an exception if the result is a failure result. + /// + public static Result Failure(Error error) + { + return new Result(default, false, error); + } + + /// + /// Returns the first failure from the specified . + /// If there is no failure, a success is returned. + /// + /// The results array. + /// + /// The first failure from the specified array,or a success it does not exist. + /// + public static Result FirstFailOrSuccess(params Result[] results) + { + foreach (var result in results) + { + if (result.IsFailure) + { + return result; + } + } + + return Success(); + } +} diff --git a/src/SharedKernel/ResultT.cs b/src/SharedKernel/ResultT.cs new file mode 100644 index 0000000..e2bbf88 --- /dev/null +++ b/src/SharedKernel/ResultT.cs @@ -0,0 +1,40 @@ +namespace SharedKernel; + +/// +/// Represents the result of some operation, with status information and possibly a value and an error. +/// +/// The result value type. +public class Result : Result +{ + private readonly TValue? _value; + + /// + /// Initializes a new instance of the class with the specified parameters. + /// + /// The result value. + /// The flag indicating if the result is successful. + /// The error. + public Result(TValue? value, bool isSuccess, Error error) + : base(isSuccess, error) + { + _value = value; + } + + /// + /// Gets the result value if the result is successful, otherwise throws an exception. + /// + /// The result value if the result is successful. + /// when is true. + public TValue Value => + IsSuccess ? _value! : throw new InvalidOperationException("The value of a failure result can't be accessed."); + + public static implicit operator Result(TValue value) + { + return Success(value); + } + + public static implicit operator Result(Error error) + { + return Failure(error); + } +} diff --git a/src/SharedKernel/SharedKernel.csproj b/src/SharedKernel/SharedKernel.csproj new file mode 100644 index 0000000..f5923fe --- /dev/null +++ b/src/SharedKernel/SharedKernel.csproj @@ -0,0 +1,7 @@ + + + net9.0 + enable + enable + + From 602c6c1ada5a2fd7a2ff397428f7b6f0f7bfc69a Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:02:22 +0100 Subject: [PATCH 02/46] add database (#4) * remove nullable properties and disable warning for empty constructor * configure DbContext * add Contact configuration * add DateTimeProvider class * configure Auditing and Soft Delete features with EF Core * refactor Soft Delete logic to remove code analysis suppression attribute * add XML comments * move the guard clause back into the recursive method * register DateTimeProvider with DI * add missing XML comments * add InitialCreate migration --- Directory.Packages.props | 30 ++-- src/PhoneForge.Domain/Contacts/Contact.cs | 10 +- .../Contacts/ContactErrors.cs | 33 ++++ src/PhoneForge.Domain/Contacts/Email.cs | 5 + src/PhoneForge.Domain/Contacts/FirstName.cs | 5 + src/PhoneForge.Domain/Contacts/LastName.cs | 5 + src/PhoneForge.Domain/Contacts/PhoneNumber.cs | 5 + .../PhoneForge.Domain.csproj | 4 +- .../DependencyInjection.cs | 25 +++ .../PhoneForge.Infrastructure.csproj | 4 +- .../Time/DateTimeProvider.cs | 12 ++ .../Configurations/ContactConfiguration.cs | 88 +++++++++++ .../DependencyInjection.cs | 25 +++ .../20251115144452_InitialCreate.Designer.cs | 146 ++++++++++++++++++ .../20251115144452_InitialCreate.cs | 40 +++++ .../PhoneForgeDbContextModelSnapshot.cs | 143 +++++++++++++++++ .../PhoneForge.Persistence.csproj | 7 +- .../PhoneForgeDbContext.cs | 116 ++++++++++++++ .../Abstractions/Data/IDbContext.cs | 22 +++ .../PhoneForge.UseCases.csproj | 7 +- .../PhoneForge.WebApi.csproj | 4 + src/PhoneForge.WebApi/Program.cs | 6 + .../appsettings.Development.json | 7 +- src/SharedKernel/IDateTimeProvider.cs | 12 ++ src/SharedKernel/ResultT.cs | 14 ++ src/SharedKernel/SharedKernel.csproj | 4 +- 26 files changed, 751 insertions(+), 28 deletions(-) create mode 100644 src/PhoneForge.Infrastructure/DependencyInjection.cs create mode 100644 src/PhoneForge.Infrastructure/Time/DateTimeProvider.cs create mode 100644 src/PhoneForge.Persistence/Configurations/ContactConfiguration.cs create mode 100644 src/PhoneForge.Persistence/DependencyInjection.cs create mode 100644 src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.Designer.cs create mode 100644 src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs create mode 100644 src/PhoneForge.Persistence/Migrations/PhoneForgeDbContextModelSnapshot.cs create mode 100644 src/PhoneForge.Persistence/PhoneForgeDbContext.cs create mode 100644 src/PhoneForge.UseCases/Abstractions/Data/IDbContext.cs create mode 100644 src/SharedKernel/IDateTimeProvider.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a27e32b..5aab9d9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,13 +1,19 @@ - - true - - - - - - - - - - + + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file diff --git a/src/PhoneForge.Domain/Contacts/Contact.cs b/src/PhoneForge.Domain/Contacts/Contact.cs index 13dad08..59908da 100644 --- a/src/PhoneForge.Domain/Contacts/Contact.cs +++ b/src/PhoneForge.Domain/Contacts/Contact.cs @@ -34,17 +34,19 @@ private Contact(FirstName firstName, LastName lastName, Email email, PhoneNumber /// /// Required by EF Core. /// +#pragma warning disable CS8618 private Contact() { } +#pragma warning restore CS8618 /// /// Gets or sets the contact first name. /// - public FirstName? FirstName { get; private set; } + public FirstName FirstName { get; private set; } /// /// Gets or sets the contact last name. /// - public LastName? LastName { get; private set; } + public LastName LastName { get; private set; } /// /// Gets the contact full name. @@ -54,12 +56,12 @@ private Contact() { } /// /// Gets or sets the contact email. /// - public Email? Email { get; private set; } + public Email Email { get; private set; } /// /// Gets or sets the contact phone number. /// - public PhoneNumber? PhoneNumber { get; private set; } + public PhoneNumber PhoneNumber { get; private set; } /// public DateTime? DeletedOnUtc { get; } diff --git a/src/PhoneForge.Domain/Contacts/ContactErrors.cs b/src/PhoneForge.Domain/Contacts/ContactErrors.cs index 6cfa1b0..6130479 100644 --- a/src/PhoneForge.Domain/Contacts/ContactErrors.cs +++ b/src/PhoneForge.Domain/Contacts/ContactErrors.cs @@ -12,8 +12,14 @@ public static class ContactErrors /// public static class FirstName { + /// + /// Gets an error indicating that the first name is null or empty. + /// public static Error NullOrEmpty => Error.Validation("FirstName.NullOrEmpty", "The first name is required."); + /// + /// Gets an error indicating that the first name exceeds the maximum allowed length. + /// public static Error LongerThanAllowed => Error.Validation("FirstName.LongerThanAllowed", "The first name is longer than allowed"); } @@ -23,8 +29,14 @@ public static class FirstName /// public static class LastName { + /// + /// Gets an error indicating that the last name is null or empty. + /// public static Error NullOrEmpty => Error.Validation("LastName.NullOrEmpty", "The last name is required."); + /// + /// Gets an error indicating that the last name exceeds the maximum allowed length. + /// public static Error LongerThanAllowed => Error.Validation("LastName.LongerThanAllowed", "The last name is longer than allowed."); } @@ -34,11 +46,20 @@ public static class LastName /// public static class Email { + /// + /// Gets an error indicating that the email is null or empty. + /// public static Error NullOrEmpty => Error.Validation("Email.NullOrEmpty", "The email is required."); + /// + /// Gets an error indicating that the email exceeds the maximum allowed length. + /// public static Error LongerThanAllowed => Error.Validation("Email.LongerThanAllowed", "The email is longer than allowed."); + /// + /// Gets an error indicating that the email format is invalid. + /// public static Error InvalidFormat => Error.Validation("Email.InvalidFormat", "The email format is invalid."); } @@ -47,14 +68,26 @@ public static class Email /// public static class PhoneNumber { + /// + /// Gets an error indicating that the phone number is null or empty. + /// public static Error NullOrEmpty => Error.Validation("PhoneNumber.NullOrEmpty", "The phone number is required."); + /// + /// Gets an error indicating that the phone number exceeds the maximum allowed length. + /// public static Error LongerThanAllowed => Error.Validation("PhoneNumber.LongerThanAllowed", "The phone number is longer than allowed"); + /// + /// Gets an error indicating that the phone number is shorter than the minimum allowed length. + /// public static Error ShorterThanAllowed => Error.Validation("PhoneNumber.ShorterThanAllowed", "The phone number is shorter than allowed."); + /// + /// Gets an error indicating that the phone number format is invalid. + /// public static Error InvalidFormat => Error.Validation("PhoneNumber.InvalidFormat", "The phone number format is invalid."); } diff --git a/src/PhoneForge.Domain/Contacts/Email.cs b/src/PhoneForge.Domain/Contacts/Email.cs index 8869991..3b6a52f 100644 --- a/src/PhoneForge.Domain/Contacts/Email.cs +++ b/src/PhoneForge.Domain/Contacts/Email.cs @@ -26,6 +26,11 @@ private Email(string value) /// public string Value { get; } + /// + /// Implicitly converts an instance to its underlying string value. + /// + /// The instance to convert. + /// The email address as a string. public static implicit operator string(Email email) { return email.Value; diff --git a/src/PhoneForge.Domain/Contacts/FirstName.cs b/src/PhoneForge.Domain/Contacts/FirstName.cs index 84e0f64..231f512 100644 --- a/src/PhoneForge.Domain/Contacts/FirstName.cs +++ b/src/PhoneForge.Domain/Contacts/FirstName.cs @@ -26,6 +26,11 @@ private FirstName(string value) /// public string Value { get; } + /// + /// Implicitly converts a instance to its underlying string value. + /// + /// The instance to convert. + /// The first name as a string. public static implicit operator string(FirstName firstName) { return firstName.Value; diff --git a/src/PhoneForge.Domain/Contacts/LastName.cs b/src/PhoneForge.Domain/Contacts/LastName.cs index a50e6b2..0ff984b 100644 --- a/src/PhoneForge.Domain/Contacts/LastName.cs +++ b/src/PhoneForge.Domain/Contacts/LastName.cs @@ -26,6 +26,11 @@ private LastName(string value) /// public string Value { get; } + /// + /// Implicitly converts a instance to its underlying string value. + /// + /// The instance to convert. + /// The last name as a string. public static implicit operator string(LastName lastName) { return lastName.Value; diff --git a/src/PhoneForge.Domain/Contacts/PhoneNumber.cs b/src/PhoneForge.Domain/Contacts/PhoneNumber.cs index 3848bdd..1c1baeb 100644 --- a/src/PhoneForge.Domain/Contacts/PhoneNumber.cs +++ b/src/PhoneForge.Domain/Contacts/PhoneNumber.cs @@ -31,6 +31,11 @@ private PhoneNumber(string value) /// public string Value { get; } + /// + /// Implicitly converts a instance to its underlying string value. + /// + /// The instance to convert. + /// The phone number as a string. public static implicit operator string(PhoneNumber phoneNumber) { return phoneNumber.Value; diff --git a/src/PhoneForge.Domain/PhoneForge.Domain.csproj b/src/PhoneForge.Domain/PhoneForge.Domain.csproj index 42d04d6..d154527 100644 --- a/src/PhoneForge.Domain/PhoneForge.Domain.csproj +++ b/src/PhoneForge.Domain/PhoneForge.Domain.csproj @@ -1,5 +1,7 @@  - + + True + diff --git a/src/PhoneForge.Infrastructure/DependencyInjection.cs b/src/PhoneForge.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..ed7681c --- /dev/null +++ b/src/PhoneForge.Infrastructure/DependencyInjection.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using PhoneForge.Infrastructure.Time; +using SharedKernel; + +namespace PhoneForge.Infrastructure; + +/// +/// Provides extension methods for registering infrastructure services. +/// +public static class DependencyInjection +{ + /// + /// Registers the infrastructure layer services with the dependency injection container. + /// + /// The service collection. + /// + /// The same instance, allowing for method chaining. + /// + public static IServiceCollection AddInfrastructure(this IServiceCollection services) + { + services.AddSingleton(); + + return services; + } +} diff --git a/src/PhoneForge.Infrastructure/PhoneForge.Infrastructure.csproj b/src/PhoneForge.Infrastructure/PhoneForge.Infrastructure.csproj index 2bcdee8..c5f906c 100644 --- a/src/PhoneForge.Infrastructure/PhoneForge.Infrastructure.csproj +++ b/src/PhoneForge.Infrastructure/PhoneForge.Infrastructure.csproj @@ -1,5 +1,7 @@  - + + True + diff --git a/src/PhoneForge.Infrastructure/Time/DateTimeProvider.cs b/src/PhoneForge.Infrastructure/Time/DateTimeProvider.cs new file mode 100644 index 0000000..1db59c3 --- /dev/null +++ b/src/PhoneForge.Infrastructure/Time/DateTimeProvider.cs @@ -0,0 +1,12 @@ +using SharedKernel; + +namespace PhoneForge.Infrastructure.Time; + +/// +/// Represents the machine date time service. +/// +internal sealed class DateTimeProvider : IDateTimeProvider +{ + /// + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/src/PhoneForge.Persistence/Configurations/ContactConfiguration.cs b/src/PhoneForge.Persistence/Configurations/ContactConfiguration.cs new file mode 100644 index 0000000..35aa4f3 --- /dev/null +++ b/src/PhoneForge.Persistence/Configurations/ContactConfiguration.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PhoneForge.Domain.Contacts; + +namespace PhoneForge.Persistence.Configurations; + +/// +/// Represents the configuration for the entity. +/// +public class ContactConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the entity. + /// + /// The builder used to configure the entity. + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(contact => contact.Id); + + builder.OwnsOne( + contact => contact.FirstName, + firstNameBuilder => + { + firstNameBuilder.WithOwner(); + + firstNameBuilder + .Property(firstName => firstName.Value) + .HasColumnName(nameof(Contact.FirstName)) + .HasMaxLength(FirstName.MaxLength) + .IsRequired(); + } + ); + + builder.OwnsOne( + contact => contact.LastName, + lastNameBuilder => + { + lastNameBuilder.WithOwner(); + + lastNameBuilder + .Property(lastName => lastName.Value) + .HasColumnName(nameof(Contact.LastName)) + .HasMaxLength(LastName.MaxLength) + .IsRequired(); + } + ); + + builder.OwnsOne( + contact => contact.Email, + emailBuilder => + { + emailBuilder.WithOwner(); + + emailBuilder + .Property(email => email.Value) + .HasColumnName(nameof(Contact.Email)) + .HasMaxLength(Email.MaxLength) + .IsRequired(); + } + ); + + builder.OwnsOne( + contact => contact.PhoneNumber, + phoneNumberBuilder => + { + phoneNumberBuilder.WithOwner(); + + phoneNumberBuilder + .Property(phoneNumber => phoneNumber.Value) + .HasColumnName(nameof(Contact.PhoneNumber)) + .HasMaxLength(PhoneNumber.MaxLength) + .IsRequired(); + } + ); + + builder.Property(contact => contact.DeletedOnUtc); + + builder.Property(contact => contact.Deleted).HasDefaultValue(false); + + builder.Property(contact => contact.CreatedOnUtc).IsRequired(); + + builder.Property(contact => contact.ModifiedOnUtc); + + builder.HasQueryFilter(contact => !contact.Deleted); + + builder.Ignore(contact => contact.FullName); + } +} diff --git a/src/PhoneForge.Persistence/DependencyInjection.cs b/src/PhoneForge.Persistence/DependencyInjection.cs new file mode 100644 index 0000000..7bb3641 --- /dev/null +++ b/src/PhoneForge.Persistence/DependencyInjection.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace PhoneForge.Persistence; + +/// +/// Provides extension methods for registering persistence services. +/// +public static class DependencyInjection +{ + /// + /// Registers the persistence layer services with the dependency injection container. + /// + /// The service collection. + /// The application configuration. + /// The same instance, allowing for method chaining. + public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("PhoneForgeDb"); + services.AddDbContext(options => options.UseSqlServer(connectionString)); + + return services; + } +} diff --git a/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.Designer.cs b/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.Designer.cs new file mode 100644 index 0000000..0623d0b --- /dev/null +++ b/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.Designer.cs @@ -0,0 +1,146 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PhoneForge.Persistence; + +#nullable disable + +namespace PhoneForge.Persistence.Migrations +{ + [DbContext(typeof(PhoneForgeDbContext))] + [Migration("20251115144452_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PhoneForge.Domain.Contacts.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOnUtc") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("DeletedOnUtc") + .HasColumnType("datetime2"); + + b.Property("ModifiedOnUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("PhoneForge.Domain.Contacts.Contact", b => + { + b.OwnsOne("PhoneForge.Domain.Contacts.Email", "Email", b1 => + { + b1.Property("ContactId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("Email"); + + b1.HasKey("ContactId"); + + b1.ToTable("Contacts"); + + b1.WithOwner() + .HasForeignKey("ContactId"); + }); + + b.OwnsOne("PhoneForge.Domain.Contacts.FirstName", "FirstName", b1 => + { + b1.Property("ContactId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("FirstName"); + + b1.HasKey("ContactId"); + + b1.ToTable("Contacts"); + + b1.WithOwner() + .HasForeignKey("ContactId"); + }); + + b.OwnsOne("PhoneForge.Domain.Contacts.LastName", "LastName", b1 => + { + b1.Property("ContactId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("LastName"); + + b1.HasKey("ContactId"); + + b1.ToTable("Contacts"); + + b1.WithOwner() + .HasForeignKey("ContactId"); + }); + + b.OwnsOne("PhoneForge.Domain.Contacts.PhoneNumber", "PhoneNumber", b1 => + { + b1.Property("ContactId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("PhoneNumber"); + + b1.HasKey("ContactId"); + + b1.ToTable("Contacts"); + + b1.WithOwner() + .HasForeignKey("ContactId"); + }); + + b.Navigation("Email") + .IsRequired(); + + b.Navigation("FirstName") + .IsRequired(); + + b.Navigation("LastName") + .IsRequired(); + + b.Navigation("PhoneNumber") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs b/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs new file mode 100644 index 0000000..73fca06 --- /dev/null +++ b/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PhoneForge.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Contacts", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + FirstName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + LastName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Email = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + PhoneNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + DeletedOnUtc = table.Column(type: "datetime2", nullable: true), + Deleted = table.Column(type: "bit", nullable: false, defaultValue: false), + CreatedOnUtc = table.Column(type: "datetime2", nullable: false), + ModifiedOnUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Contacts", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Contacts"); + } + } +} diff --git a/src/PhoneForge.Persistence/Migrations/PhoneForgeDbContextModelSnapshot.cs b/src/PhoneForge.Persistence/Migrations/PhoneForgeDbContextModelSnapshot.cs new file mode 100644 index 0000000..f58477d --- /dev/null +++ b/src/PhoneForge.Persistence/Migrations/PhoneForgeDbContextModelSnapshot.cs @@ -0,0 +1,143 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PhoneForge.Persistence; + +#nullable disable + +namespace PhoneForge.Persistence.Migrations +{ + [DbContext(typeof(PhoneForgeDbContext))] + partial class PhoneForgeDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PhoneForge.Domain.Contacts.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOnUtc") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("DeletedOnUtc") + .HasColumnType("datetime2"); + + b.Property("ModifiedOnUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("PhoneForge.Domain.Contacts.Contact", b => + { + b.OwnsOne("PhoneForge.Domain.Contacts.Email", "Email", b1 => + { + b1.Property("ContactId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("Email"); + + b1.HasKey("ContactId"); + + b1.ToTable("Contacts"); + + b1.WithOwner() + .HasForeignKey("ContactId"); + }); + + b.OwnsOne("PhoneForge.Domain.Contacts.FirstName", "FirstName", b1 => + { + b1.Property("ContactId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("FirstName"); + + b1.HasKey("ContactId"); + + b1.ToTable("Contacts"); + + b1.WithOwner() + .HasForeignKey("ContactId"); + }); + + b.OwnsOne("PhoneForge.Domain.Contacts.LastName", "LastName", b1 => + { + b1.Property("ContactId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("LastName"); + + b1.HasKey("ContactId"); + + b1.ToTable("Contacts"); + + b1.WithOwner() + .HasForeignKey("ContactId"); + }); + + b.OwnsOne("PhoneForge.Domain.Contacts.PhoneNumber", "PhoneNumber", b1 => + { + b1.Property("ContactId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("PhoneNumber"); + + b1.HasKey("ContactId"); + + b1.ToTable("Contacts"); + + b1.WithOwner() + .HasForeignKey("ContactId"); + }); + + b.Navigation("Email") + .IsRequired(); + + b.Navigation("FirstName") + .IsRequired(); + + b.Navigation("LastName") + .IsRequired(); + + b.Navigation("PhoneNumber") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PhoneForge.Persistence/PhoneForge.Persistence.csproj b/src/PhoneForge.Persistence/PhoneForge.Persistence.csproj index 2bcdee8..c2aaa41 100644 --- a/src/PhoneForge.Persistence/PhoneForge.Persistence.csproj +++ b/src/PhoneForge.Persistence/PhoneForge.Persistence.csproj @@ -1,6 +1,11 @@  - + + True + + + + diff --git a/src/PhoneForge.Persistence/PhoneForgeDbContext.cs b/src/PhoneForge.Persistence/PhoneForgeDbContext.cs new file mode 100644 index 0000000..7ba9647 --- /dev/null +++ b/src/PhoneForge.Persistence/PhoneForgeDbContext.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using PhoneForge.Domain.Contacts; +using PhoneForge.UseCases.Abstractions.Data; +using SharedKernel; + +namespace PhoneForge.Persistence; + +/// +/// Represents the applications database context. +/// +public sealed class PhoneForgeDbContext : DbContext, IDbContext +{ + private readonly IDateTimeProvider _dateTimeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The database context options. + /// The current date and time in UTC format. + public PhoneForgeDbContext(DbContextOptions options, IDateTimeProvider dateTimeProvider) + : base(options) + { + _dateTimeProvider = dateTimeProvider; + } + + /// + public DbSet Contacts { get; set; } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(PhoneForgeDbContext).Assembly); + } + + /// + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var utcNow = _dateTimeProvider.UtcNow; + + UpdateAuditableEntities(utcNow); + + UpdateSoftDeletableEntities(utcNow); + + return await base.SaveChangesAsync(cancellationToken); + } + + /// + /// Updates the entities implementing the interface. + /// + /// The current date and time in UTC format. + private void UpdateAuditableEntities(DateTime utcNow) + { + var entities = ChangeTracker.Entries().ToList(); + + foreach (var entity in entities) + { + if (entity.State == EntityState.Added) + { + entity.Property(nameof(IAuditableEntity.CreatedOnUtc)).CurrentValue = utcNow; + } + + if (entity.State == EntityState.Modified) + { + entity.Property(nameof(IAuditableEntity.ModifiedOnUtc)).CurrentValue = utcNow; + } + } + } + + /// + /// Updates the entities implementing the interface. + /// + /// The current date and time in UTC format. + private void UpdateSoftDeletableEntities(DateTime utcNow) + { + var entities = ChangeTracker + .Entries() + .Where(e => e.State == EntityState.Deleted) + .ToList(); + + foreach (var entity in entities) + { + entity.Property(nameof(ISoftDeletableEntity.DeletedOnUtc)).CurrentValue = utcNow; + + entity.Property(nameof(ISoftDeletableEntity.Deleted)).CurrentValue = true; + + entity.State = EntityState.Modified; + + UpdateDeletedEntityReferencesToUnchanged(entity); + } + } + + /// + /// Updates the specified entity entry's referenced entries in the deleted state to the unchanged state. + /// This method is recursive. + /// + /// The entity entry. + private static void UpdateDeletedEntityReferencesToUnchanged(EntityEntry entity) + { + if (entity.References.Any()) + { + return; + } + + var references = entity + .References.Where(r => r.TargetEntry?.State == EntityState.Deleted) + .Select(r => r.TargetEntry!); + + foreach (var reference in references) + { + reference.State = EntityState.Unchanged; + + UpdateDeletedEntityReferencesToUnchanged(reference); + } + } +} diff --git a/src/PhoneForge.UseCases/Abstractions/Data/IDbContext.cs b/src/PhoneForge.UseCases/Abstractions/Data/IDbContext.cs new file mode 100644 index 0000000..32d5e43 --- /dev/null +++ b/src/PhoneForge.UseCases/Abstractions/Data/IDbContext.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using PhoneForge.Domain.Contacts; + +namespace PhoneForge.UseCases.Abstractions.Data; + +/// +/// Represents the application database context interface. +/// +public interface IDbContext +{ + /// + /// Gets the database set for contact entity. + /// + DbSet Contacts { get; set; } + + /// + /// Saves all of the pending changes in the unit of work. + /// + /// The cancellation token. + /// The number of entities that have been saved. + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj b/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj index 0f0c5f4..e4f6fed 100644 --- a/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj +++ b/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj @@ -1,6 +1,11 @@  - + + True + + + + diff --git a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj b/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj index ed04919..c157e0e 100644 --- a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj +++ b/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj @@ -4,6 +4,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/src/PhoneForge.WebApi/Program.cs b/src/PhoneForge.WebApi/Program.cs index d4fd6ee..1cf2906 100644 --- a/src/PhoneForge.WebApi/Program.cs +++ b/src/PhoneForge.WebApi/Program.cs @@ -1,5 +1,11 @@ +using PhoneForge.Infrastructure; +using PhoneForge.Persistence; + var builder = WebApplication.CreateBuilder(args); +builder.Services.AddInfrastructure(); +builder.Services.AddPersistence(builder.Configuration); + builder.Services.AddOpenApi(); var app = builder.Build(); diff --git a/src/PhoneForge.WebApi/appsettings.Development.json b/src/PhoneForge.WebApi/appsettings.Development.json index 0c208ae..ed57909 100644 --- a/src/PhoneForge.WebApi/appsettings.Development.json +++ b/src/PhoneForge.WebApi/appsettings.Development.json @@ -1,8 +1,5 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "ConnectionStrings": { + "PhoneForgeDb": "Data Source=(localdb)\\MSSQLLocalDB; Initial Catalog=PhoneForgeDb; Integrated Security=SSPI" } } diff --git a/src/SharedKernel/IDateTimeProvider.cs b/src/SharedKernel/IDateTimeProvider.cs new file mode 100644 index 0000000..ed44449 --- /dev/null +++ b/src/SharedKernel/IDateTimeProvider.cs @@ -0,0 +1,12 @@ +namespace SharedKernel; + +/// +/// Represents the interface for getting the current date and time. +/// +public interface IDateTimeProvider +{ + /// + /// Gets the current date and time in UTC format. + /// + DateTime UtcNow { get; } +} diff --git a/src/SharedKernel/ResultT.cs b/src/SharedKernel/ResultT.cs index e2bbf88..a05c779 100644 --- a/src/SharedKernel/ResultT.cs +++ b/src/SharedKernel/ResultT.cs @@ -28,11 +28,25 @@ public Result(TValue? value, bool isSuccess, Error error) public TValue Value => IsSuccess ? _value! : throw new InvalidOperationException("The value of a failure result can't be accessed."); + /// + /// Implicitly converts a value of type + /// into a successful . + /// + /// The value to wrap in a success result. + /// A successful containing the specified value. public static implicit operator Result(TValue value) { return Success(value); } + /// + /// Implicitly converts an + /// into a failed . + /// + /// The error to wrap in a failure result. + /// + /// A failed containing the specified error. + /// public static implicit operator Result(Error error) { return Failure(error); diff --git a/src/SharedKernel/SharedKernel.csproj b/src/SharedKernel/SharedKernel.csproj index f5923fe..9f31b7d 100644 --- a/src/SharedKernel/SharedKernel.csproj +++ b/src/SharedKernel/SharedKernel.csproj @@ -1,7 +1,5 @@  - net9.0 - enable - enable + True From fccaf2cb47bb07e4a90d9de46f08657e2bcf55ab Mon Sep 17 00:00:00 2001 From: nwdorian Date: Sat, 15 Nov 2025 17:17:10 +0100 Subject: [PATCH 03/46] refactor email validation logic --- src/PhoneForge.Domain/Contacts/Email.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/PhoneForge.Domain/Contacts/Email.cs b/src/PhoneForge.Domain/Contacts/Email.cs index 3b6a52f..1e4e36d 100644 --- a/src/PhoneForge.Domain/Contacts/Email.cs +++ b/src/PhoneForge.Domain/Contacts/Email.cs @@ -1,3 +1,4 @@ +using System.Net.Mail; using SharedKernel; namespace PhoneForge.Domain.Contacts; @@ -53,11 +54,25 @@ public static Result Create(string? email) return ContactErrors.Email.LongerThanAllowed; } - if (!email.Contains('@')) + if (!IsValidEmail(email)) { return ContactErrors.Email.InvalidFormat; } return new Email(email); } + + private static bool IsValidEmail(string email) + { + try + { + var mailAdress = new MailAddress(email); + + return mailAdress.Address == email; + } + catch + { + return false; + } + } } From 266fa2adf6e88e03302d7cadad412f5cb259ede1 Mon Sep 17 00:00:00 2001 From: nwdorian Date: Sun, 16 Nov 2025 23:01:02 +0100 Subject: [PATCH 04/46] add Create Contact endpoint --- .editorconfig | 2 +- Directory.Packages.props | 4 + src/PhoneForge.Domain/Contacts/Contact.cs | 16 +++- .../Contacts/ContactErrors.cs | 75 +++++++++++++--- .../DependencyInjection.cs | 12 ++- .../Contacts/Create/CreateContact.cs | 83 +++++++++++++++++ .../Contacts/Create/CreateContactCommand.cs | 10 +++ .../Contacts/Create/CreateContactResponse.cs | 21 +++++ .../DependencyInjection.cs | 22 +++++ .../PhoneForge.UseCases.csproj | 1 + src/PhoneForge.WebApi/Endpoints/Routes.cs | 25 ++++++ .../Contacts/ContactsEndpointsExtensions.cs | 18 ++++ .../Contacts/Create/CreateContactEndpoint.cs | 44 +++++++++ .../Contacts/Create/CreateContactRequest.cs | 10 +++ .../Create/CreateContactRequestValidator.cs | 20 +++++ .../Extensions/MiddlewareExtensions.cs | 62 +++++++++++++ .../Extensions/ResultExtensions.cs | 50 +++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 63 +++++++++++++ src/PhoneForge.WebApi/HttpFiles/Contacts.http | 16 ++++ .../Infrastructure/CustomResults.cs | 89 +++++++++++++++++++ .../PhoneForge.WebApi.csproj | 3 + src/PhoneForge.WebApi/PhoneForge.WebApi.http | 6 -- src/PhoneForge.WebApi/Program.cs | 15 +--- 23 files changed, 632 insertions(+), 35 deletions(-) create mode 100644 src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs create mode 100644 src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs create mode 100644 src/PhoneForge.UseCases/Contacts/Create/CreateContactResponse.cs create mode 100644 src/PhoneForge.UseCases/DependencyInjection.cs create mode 100644 src/PhoneForge.WebApi/Endpoints/Routes.cs create mode 100644 src/PhoneForge.WebApi/Endpoints/V1/Contacts/ContactsEndpointsExtensions.cs create mode 100644 src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactEndpoint.cs create mode 100644 src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs create mode 100644 src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs create mode 100644 src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs create mode 100644 src/PhoneForge.WebApi/Extensions/ResultExtensions.cs create mode 100644 src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/PhoneForge.WebApi/HttpFiles/Contacts.http create mode 100644 src/PhoneForge.WebApi/Infrastructure/CustomResults.cs delete mode 100644 src/PhoneForge.WebApi/PhoneForge.WebApi.http diff --git a/.editorconfig b/.editorconfig index 678c75a..53d3a9e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -145,7 +145,7 @@ csharp_preserve_single_line_blocks = true # Custom rules # Maximum line length -max_line_length = 120 +max_line_length = 90 # IDE0290 Use primary constructor csharp_style_prefer_primary_constructors = false diff --git a/Directory.Packages.props b/Directory.Packages.props index 5aab9d9..51c47f8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,10 @@ true + + + + diff --git a/src/PhoneForge.Domain/Contacts/Contact.cs b/src/PhoneForge.Domain/Contacts/Contact.cs index 59908da..1ab757e 100644 --- a/src/PhoneForge.Domain/Contacts/Contact.cs +++ b/src/PhoneForge.Domain/Contacts/Contact.cs @@ -14,7 +14,12 @@ public sealed class Contact : Entity, ISoftDeletableEntity, IAuditableEntity /// The contact last name. /// The contact email. /// The contact phone number. - private Contact(FirstName firstName, LastName lastName, Email email, PhoneNumber phoneNumber) + private Contact( + FirstName firstName, + LastName lastName, + Email email, + PhoneNumber phoneNumber + ) : base(Guid.NewGuid()) { Ensure.NotNull(firstName, "The first name is required", nameof(firstName)); @@ -51,7 +56,7 @@ private Contact() { } /// /// Gets the contact full name. /// - public string FullName => $"{FirstName} {LastName}"; + public string FullName => $"{FirstName.Value} {LastName.Value}"; /// /// Gets or sets the contact email. @@ -83,7 +88,12 @@ private Contact() { } /// The email. /// The phone number. /// The newly created contact instance. - public static Contact Create(FirstName firstName, LastName lastName, Email email, PhoneNumber phoneNumber) + public static Contact Create( + FirstName firstName, + LastName lastName, + Email email, + PhoneNumber phoneNumber + ) { var contact = new Contact(firstName, lastName, email, phoneNumber); return contact; diff --git a/src/PhoneForge.Domain/Contacts/ContactErrors.cs b/src/PhoneForge.Domain/Contacts/ContactErrors.cs index 6130479..e3f4926 100644 --- a/src/PhoneForge.Domain/Contacts/ContactErrors.cs +++ b/src/PhoneForge.Domain/Contacts/ContactErrors.cs @@ -7,6 +7,12 @@ namespace PhoneForge.Domain.Contacts; /// public static class ContactErrors { + /// + /// Gets an error indicating that the email is not unique. + /// + public static Error EmailNotUnique => + Error.Conflict("Contact.EmailNotUnique", "The provided email is not unique."); + /// /// Contains the first name errors. /// @@ -15,13 +21,23 @@ public static class FirstName /// /// Gets an error indicating that the first name is null or empty. /// - public static Error NullOrEmpty => Error.Validation("FirstName.NullOrEmpty", "The first name is required."); + public static Error NullOrEmpty => + Error.Validation("FirstName.NullOrEmpty", "The first name is required."); /// /// Gets an error indicating that the first name exceeds the maximum allowed length. /// public static Error LongerThanAllowed => - Error.Validation("FirstName.LongerThanAllowed", "The first name is longer than allowed"); + Error.Validation( + "FirstName.LongerThanAllowed", + "The first name is longer than allowed" + ); + + /// + /// Gets an error indicating that the first name is required. + /// + public static Error IsRequired => + Error.Validation("FirstName.IsRequired", "The first name is required."); } /// @@ -32,13 +48,23 @@ public static class LastName /// /// Gets an error indicating that the last name is null or empty. /// - public static Error NullOrEmpty => Error.Validation("LastName.NullOrEmpty", "The last name is required."); + public static Error NullOrEmpty => + Error.Validation("LastName.NullOrEmpty", "The last name is required."); /// /// Gets an error indicating that the last name exceeds the maximum allowed length. /// public static Error LongerThanAllowed => - Error.Validation("LastName.LongerThanAllowed", "The last name is longer than allowed."); + Error.Validation( + "LastName.LongerThanAllowed", + "The last name is longer than allowed." + ); + + /// + /// Gets an error indicating that the last name is required. + /// + public static Error IsRequired => + Error.Validation("LastName.IsRequired", "The last name is required."); } /// @@ -49,18 +75,29 @@ public static class Email /// /// Gets an error indicating that the email is null or empty. /// - public static Error NullOrEmpty => Error.Validation("Email.NullOrEmpty", "The email is required."); + public static Error NullOrEmpty => + Error.Validation("Email.NullOrEmpty", "The email is required."); /// /// Gets an error indicating that the email exceeds the maximum allowed length. /// public static Error LongerThanAllowed => - Error.Validation("Email.LongerThanAllowed", "The email is longer than allowed."); + Error.Validation( + "Email.LongerThanAllowed", + "The email is longer than allowed." + ); /// /// Gets an error indicating that the email format is invalid. /// - public static Error InvalidFormat => Error.Validation("Email.InvalidFormat", "The email format is invalid."); + public static Error InvalidFormat => + Error.Validation("Email.InvalidFormat", "The email format is invalid."); + + /// + /// Gets an error indicating that the email is required. + /// + public static Error IsRequired => + Error.Validation("Email.IsRequired", "The email is required."); } /// @@ -71,24 +108,40 @@ public static class PhoneNumber /// /// Gets an error indicating that the phone number is null or empty. /// - public static Error NullOrEmpty => Error.Validation("PhoneNumber.NullOrEmpty", "The phone number is required."); + public static Error NullOrEmpty => + Error.Validation("PhoneNumber.NullOrEmpty", "The phone number is required."); /// /// Gets an error indicating that the phone number exceeds the maximum allowed length. /// public static Error LongerThanAllowed => - Error.Validation("PhoneNumber.LongerThanAllowed", "The phone number is longer than allowed"); + Error.Validation( + "PhoneNumber.LongerThanAllowed", + "The phone number is longer than allowed" + ); /// /// Gets an error indicating that the phone number is shorter than the minimum allowed length. /// public static Error ShorterThanAllowed => - Error.Validation("PhoneNumber.ShorterThanAllowed", "The phone number is shorter than allowed."); + Error.Validation( + "PhoneNumber.ShorterThanAllowed", + "The phone number is shorter than allowed." + ); /// /// Gets an error indicating that the phone number format is invalid. /// public static Error InvalidFormat => - Error.Validation("PhoneNumber.InvalidFormat", "The phone number format is invalid."); + Error.Validation( + "PhoneNumber.InvalidFormat", + "The phone number format is invalid." + ); + + /// + /// Gets an error indicating that the phone number is required. + /// + public static Error IsRequired => + Error.Validation("PhoneNumber.IsRequired", "The phone number is required."); } } diff --git a/src/PhoneForge.Persistence/DependencyInjection.cs b/src/PhoneForge.Persistence/DependencyInjection.cs index 7bb3641..725efee 100644 --- a/src/PhoneForge.Persistence/DependencyInjection.cs +++ b/src/PhoneForge.Persistence/DependencyInjection.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using PhoneForge.UseCases.Abstractions.Data; namespace PhoneForge.Persistence; @@ -15,10 +16,17 @@ public static class DependencyInjection /// The service collection. /// The application configuration. /// The same instance, allowing for method chaining. - public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddPersistence( + this IServiceCollection services, + IConfiguration configuration + ) { var connectionString = configuration.GetConnectionString("PhoneForgeDb"); - services.AddDbContext(options => options.UseSqlServer(connectionString)); + services.AddDbContext(options => + options.UseSqlServer(connectionString) + ); + + services.AddScoped(); return services; } diff --git a/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs b/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs new file mode 100644 index 0000000..0b5b169 --- /dev/null +++ b/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using PhoneForge.Domain.Contacts; +using PhoneForge.UseCases.Abstractions.Data; +using SharedKernel; + +namespace PhoneForge.UseCases.Contacts.Create; + +/// +/// Represents the handler. +/// +public sealed class CreateContact +{ + private readonly IDbContext _context; + + /// + /// Initializes a new instance of the class. + /// + /// The database context used to persist the new contact. + public CreateContact(IDbContext context) + { + _context = context; + } + + /// + /// Handles a request. + /// + /// Response from the request. + public async Task> Handle( + CreateContactCommand request, + CancellationToken cancellationToken + ) + { + var firstNameResult = FirstName.Create(request.FirstName); + var lastNameResult = LastName.Create(request.LastName); + var emailResult = Email.Create(request.Email); + var phoneNumberResult = PhoneNumber.Create(request.PhoneNumber); + + var firstFailOrSuccess = Result.FirstFailOrSuccess( + firstNameResult, + lastNameResult, + emailResult, + phoneNumberResult + ); + + if (firstFailOrSuccess.IsFailure) + { + return firstFailOrSuccess.Error; + } + + if ( + await _context.Contacts.AnyAsync( + c => c.Email.Value == request.Email, + cancellationToken + ) + ) + { + return ContactErrors.EmailNotUnique; + } + + var contact = Contact.Create( + firstNameResult.Value, + lastNameResult.Value, + emailResult.Value, + phoneNumberResult.Value + ); + + _context.Contacts.Add(contact); + + await _context.SaveChangesAsync(cancellationToken); + + var response = new CreateContactResponse( + contact.Id, + contact.FirstName, + contact.LastName, + contact.FullName, + contact.Email, + contact.PhoneNumber, + contact.CreatedOnUtc + ); + + return response; + } +} diff --git a/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs b/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs new file mode 100644 index 0000000..4159f11 --- /dev/null +++ b/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs @@ -0,0 +1,10 @@ +namespace PhoneForge.UseCases.Contacts.Create; + +/// +/// Represents a command for creating a new contact. +/// +/// The first name of the contact. +/// The last name of the contact. +/// The email of the contact. +/// The phone number of the contact. +public sealed record CreateContactCommand(string FirstName, string LastName, string Email, string PhoneNumber); diff --git a/src/PhoneForge.UseCases/Contacts/Create/CreateContactResponse.cs b/src/PhoneForge.UseCases/Contacts/Create/CreateContactResponse.cs new file mode 100644 index 0000000..0f6828e --- /dev/null +++ b/src/PhoneForge.UseCases/Contacts/Create/CreateContactResponse.cs @@ -0,0 +1,21 @@ +namespace PhoneForge.UseCases.Contacts.Create; + +/// +/// Represents the response returned after creating a new contact. +/// +/// The unique identifier of the contact. +/// The first name of the contact. +/// The last name of the contact. +/// The full name of the contact. +/// The email address of the contact. +/// The phone number of the contact. +/// The date and time when the contact was created in UTC format. +public sealed record CreateContactResponse( + Guid Id, + string FirstName, + string LastName, + string FullName, + string Email, + string PhoneNumber, + DateTime CreatedOnUtc +); diff --git a/src/PhoneForge.UseCases/DependencyInjection.cs b/src/PhoneForge.UseCases/DependencyInjection.cs new file mode 100644 index 0000000..c5bddb8 --- /dev/null +++ b/src/PhoneForge.UseCases/DependencyInjection.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using PhoneForge.UseCases.Contacts.Create; + +namespace PhoneForge.UseCases; + +/// +/// Provides extension methods for registering use case services. +/// +public static class DependencyInjection +{ + /// + /// Registers the use case layer services with the dependency injection container. + /// + /// The service collection. + /// The same instance, allowing for method chaining. + public static IServiceCollection AddUseCases(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj b/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj index e4f6fed..5d5885b 100644 --- a/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj +++ b/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj @@ -3,6 +3,7 @@ True + diff --git a/src/PhoneForge.WebApi/Endpoints/Routes.cs b/src/PhoneForge.WebApi/Endpoints/Routes.cs new file mode 100644 index 0000000..f3720cf --- /dev/null +++ b/src/PhoneForge.WebApi/Endpoints/Routes.cs @@ -0,0 +1,25 @@ +namespace PhoneForge.WebApi.Endpoints; + +/// +/// Provides route definitions for the application's endpoints. +/// +public static class Routes +{ + /// + /// Contains route definitions related to contacts. + /// + public static class Contacts + { + private const string Base = "contacts"; + + /// + /// The route used for creating a new contact. + /// + public const string Create = Base; + + /// + /// The route used for retrieving a contact by identifier. + /// + public const string GetById = $"{Base}/{{id:guid}}"; + } +} diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/ContactsEndpointsExtensions.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/ContactsEndpointsExtensions.cs new file mode 100644 index 0000000..d9ef3e1 --- /dev/null +++ b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/ContactsEndpointsExtensions.cs @@ -0,0 +1,18 @@ +using PhoneForge.WebApi.Endpoints.V1.Contacts.Create; + +namespace PhoneForge.WebApi.Endpoints.V1.Contacts; + +/// +/// Provides extension method for mapping contact-related API endpoints. +/// +public static class ContactsEndpointsExtensions +{ + /// + /// Maps all contact-related endpoints to the application's routing pipeline. + /// + /// The route builder used to configure the endpoints. + public static void MapContactsEndpoints(this IEndpointRouteBuilder app) + { + app.MapCreateContact(); + } +} diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactEndpoint.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactEndpoint.cs new file mode 100644 index 0000000..e5705f3 --- /dev/null +++ b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactEndpoint.cs @@ -0,0 +1,44 @@ +using PhoneForge.UseCases.Contacts.Create; +using PhoneForge.WebApi.Extensions; +using PhoneForge.WebApi.Infrastructure; + +namespace PhoneForge.WebApi.Endpoints.V1.Contacts.Create; + +/// +/// Provides endpoint mappings related to creating contacts. +/// +public static class CreateContactEndpoint +{ + /// + /// The name of the endpoint used for creating a new contact. + /// + public const string Name = "CreateContact"; + + /// + /// Maps the endpoint responsible for creating a new contact. + /// + /// The route builder used to configure the endpoint. + public static void MapCreateContact(this IEndpointRouteBuilder app) + { + app.MapPost( + Routes.Contacts.Create, + async ( + CreateContactRequest request, + CreateContact useCase, + CancellationToken cancellationToken + ) => + { + var command = new CreateContactCommand( + request.FirstName, + request.LastName, + request.Email, + request.PhoneNumber + ); + + var result = await useCase.Handle(command, cancellationToken); + + return result.Match(Results.Ok, CustomResults.Problem); + } + ); + } +} diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs new file mode 100644 index 0000000..045fc8c --- /dev/null +++ b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs @@ -0,0 +1,10 @@ +namespace PhoneForge.WebApi.Endpoints.V1.Contacts.Create; + +/// +/// Represents the request to create a contact. +/// +/// The first name of the contact. +/// The last name of the contact. +/// The email of the contact. +/// The phone number of the contact. +public sealed record CreateContactRequest(string FirstName, string LastName, string Email, string PhoneNumber); diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs new file mode 100644 index 0000000..d17ffe7 --- /dev/null +++ b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace PhoneForge.WebApi.Endpoints.V1.Contacts.Create; + +/// +/// Represents the class. +/// +public class CreateContactRequestValidator : AbstractValidator +{ + /// + /// Initializes a new instance of the class. + /// + public CreateContactRequestValidator() + { + RuleFor(x => x.FirstName).NotEmpty().WithMessage("The first name is required."); + RuleFor(x => x.LastName).NotEmpty().WithMessage("The last name is required."); + RuleFor(x => x.Email).NotEmpty().WithMessage("The email is required."); + RuleFor(x => x.PhoneNumber).NotEmpty().WithMessage("The phone number is required."); + } +} diff --git a/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs b/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs new file mode 100644 index 0000000..8974bbd --- /dev/null +++ b/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs @@ -0,0 +1,62 @@ +using Asp.Versioning; +using Microsoft.EntityFrameworkCore; +using PhoneForge.Persistence; +using PhoneForge.WebApi.Endpoints.V1.Contacts; + +namespace PhoneForge.WebApi.Extensions; + +/// +/// Provides extension methods for registering application middleware. +/// +public static class MiddlewareExtensions +{ + /// + /// Registers the web application middleware. + /// + /// The web application. + /// The same instance, allowing for method chaning. + public static async Task UseWebApplicationMiddleware( + this WebApplication app + ) + { + app.MapV1Endpoints(); + + app.UseOpenApi(); + app.UseHttpsRedirection(); + + await app.ApplyMigrations(); + + return app; + } + + private static void MapV1Endpoints(this WebApplication app) + { + var apiVersionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var versionedGroup = app.MapGroup("api/v{apiVersion:apiVersion}") + .WithApiVersionSet(apiVersionSet); + + versionedGroup.MapContactsEndpoints(); + } + + private static void UseOpenApi(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + } + } + + private static async Task ApplyMigrations(this IApplicationBuilder app) + { + using var scope = app.ApplicationServices.CreateScope(); + + using var dbContext = + scope.ServiceProvider.GetRequiredService(); + + await dbContext.Database.MigrateAsync(); + } +} diff --git a/src/PhoneForge.WebApi/Extensions/ResultExtensions.cs b/src/PhoneForge.WebApi/Extensions/ResultExtensions.cs new file mode 100644 index 0000000..e48a36f --- /dev/null +++ b/src/PhoneForge.WebApi/Extensions/ResultExtensions.cs @@ -0,0 +1,50 @@ +using SharedKernel; + +namespace PhoneForge.WebApi.Extensions; + +/// +/// Contains extension methods for the result class. +/// +public static class ResultExtensions +{ + /// + /// Matches the success status of the result to the corresponding functions. + /// + /// The result type. + /// The result. + /// The on-success function. + /// The on-failure function. + /// + /// The result of the on-success function if the result is a success result, + /// otherwise the result of the failure result. + /// + public static TOut Match( + this Result result, + Func onSuccess, + Func onFailure + ) + { + return result.IsSuccess ? onSuccess() : onFailure(result); + } + + /// + ///Matches the success status of the result to the corresponding functions. + /// + /// The result type. + /// The output result type. + /// The result. + /// The on-success function. + /// The on-failure function. + /// + /// The result of the on-success function if the result is a success result, + /// otherwise the result of the failure result. + /// + public static TOut Match( + this Result result, + Func onSuccess, + Func, TOut> onFailure + ) + { + return result.IsSuccess ? onSuccess(result.Value) : onFailure(result); + } +} diff --git a/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs b/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..99c1ec3 --- /dev/null +++ b/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,63 @@ +using Asp.Versioning; +using FluentValidation; +using PhoneForge.Infrastructure; +using PhoneForge.Persistence; +using PhoneForge.UseCases; + +namespace PhoneForge.WebApi.Extensions; + +/// +/// Provides extension methods for registering application services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the web application services with the dependency injection container. + /// + /// The service collection. + /// The application configuration. + /// The same instance, allowing for method chaining. + public static IServiceCollection AddWebApplicationServices( + this IServiceCollection services, + IConfiguration configuration + ) + { + services.AddPresentation(); + services.AddUseCases(); + services.AddInfrastructure(); + services.AddPersistence(configuration); + + services.AddFluentValidation(); + + services.AddApiVersioning(); + + return services; + } + + private static void AddPresentation(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + services.AddOpenApi(); + } + + private static void AddFluentValidation(this IServiceCollection services) + { + services.AddValidatorsFromAssembly(typeof(Program).Assembly); + } + + private static void AddApiVersioning(this IServiceCollection services) + { + services + .AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1); + options.ReportApiVersions = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'V"; + options.SubstituteApiVersionInUrl = true; + }); + } +} diff --git a/src/PhoneForge.WebApi/HttpFiles/Contacts.http b/src/PhoneForge.WebApi/HttpFiles/Contacts.http new file mode 100644 index 0000000..34f215d --- /dev/null +++ b/src/PhoneForge.WebApi/HttpFiles/Contacts.http @@ -0,0 +1,16 @@ +@HostAddress = http://localhost:5111 + +GET {{HostAddress}}/ +Accept: application/json + +### Create contact + +POST {{HostAddress}}/api/v1/contacts +Content-type: application/json + +{ + "firstName": "John", + "lastName": "Doe", + "email": "jdoe@gmail.com", + "phoneNumber": "091987654321" +} diff --git a/src/PhoneForge.WebApi/Infrastructure/CustomResults.cs b/src/PhoneForge.WebApi/Infrastructure/CustomResults.cs new file mode 100644 index 0000000..ab5762d --- /dev/null +++ b/src/PhoneForge.WebApi/Infrastructure/CustomResults.cs @@ -0,0 +1,89 @@ +using SharedKernel; + +namespace PhoneForge.WebApi.Infrastructure; + +/// +/// Provides custom result helpers for converting errors +/// into standardized HTTP problem responses. +/// +public static class CustomResults +{ + /// + /// Creates a standardized representing an HTTP problem response + /// based on the specified . + /// + /// + /// The result containing the error information. + /// Must represent a failure; otherwise an is thrown. + /// + /// + /// An containing a problem details response with + /// appropriate title, detail, type, status code, and error metadata. + /// + /// + /// Thrown when the provided result represents a successful operation. + /// + public static IResult Problem(Result result) + { + if (result.IsSuccess) + { + throw new InvalidOperationException(); + } + + return Results.Problem( + title: GetTitle(result.Error), + detail: GetDetail(result.Error), + type: GetType(result.Error.Type), + statusCode: GetStatusCode(result.Error.Type), + extensions: new Dictionary + { + { "errors", new[] { result.Error } }, + } + ); + + static string GetTitle(Error error) + { + return error.Type switch + { + ErrorType.Validation => error.Code, + ErrorType.NotFound => error.Code, + ErrorType.Conflict => error.Code, + _ => "Server failure", + }; + } + + static string GetDetail(Error error) + { + return error.Type switch + { + ErrorType.Validation => error.Description, + ErrorType.NotFound => error.Description, + ErrorType.Conflict => error.Description, + _ => "An unexpected error occurred", + }; + } + + static string GetType(ErrorType errorType) + { + return errorType switch + { + ErrorType.Validation => + "https://tools.ietf.org/html/rfc7231#section-6.5.1", + ErrorType.NotFound => "https://tools.ietf.org/html/rfc7231#section-6.5.4", + ErrorType.Conflict => "https://tools.ietf.org/html/rfc7231#section-6.5.8", + _ => "https://tools.ietf.org/html/rfc7231#section-6.6.1", + }; + } + + static int GetStatusCode(ErrorType errorType) + { + return errorType switch + { + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + _ => StatusCodes.Status500InternalServerError, + }; + } + } +} diff --git a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj b/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj index c157e0e..bba13cb 100644 --- a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj +++ b/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj @@ -3,6 +3,9 @@ True + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/PhoneForge.WebApi/PhoneForge.WebApi.http b/src/PhoneForge.WebApi/PhoneForge.WebApi.http deleted file mode 100644 index fa2f29a..0000000 --- a/src/PhoneForge.WebApi/PhoneForge.WebApi.http +++ /dev/null @@ -1,6 +0,0 @@ -@PhoneForge.WebApi_HostAddress = http://localhost:5111 - -GET {{PhoneForge.WebApi_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/PhoneForge.WebApi/Program.cs b/src/PhoneForge.WebApi/Program.cs index 1cf2906..2c459e4 100644 --- a/src/PhoneForge.WebApi/Program.cs +++ b/src/PhoneForge.WebApi/Program.cs @@ -1,20 +1,11 @@ -using PhoneForge.Infrastructure; -using PhoneForge.Persistence; +using PhoneForge.WebApi.Extensions; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddInfrastructure(); -builder.Services.AddPersistence(builder.Configuration); - -builder.Services.AddOpenApi(); +builder.Services.AddWebApplicationServices(builder.Configuration); var app = builder.Build(); -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); +await app.UseWebApplicationMiddleware(); await app.RunAsync(); From 1492ab446895c659d6b199c25b72d5dfa6a810ed Mon Sep 17 00:00:00 2001 From: nwdorian Date: Sun, 16 Nov 2025 23:02:30 +0100 Subject: [PATCH 05/46] apply formatting --- Directory.Packages.props | 44 ++++++++-------- .../20251115144452_InitialCreate.cs | 51 +++++++++++++++---- .../PhoneForgeDbContext.cs | 22 +++++--- .../Contacts/Create/CreateContactCommand.cs | 7 ++- .../Contacts/Create/CreateContactRequest.cs | 7 ++- .../Create/CreateContactRequestValidator.cs | 4 +- src/SharedKernel/Error.cs | 6 ++- src/SharedKernel/ResultT.cs | 6 ++- 8 files changed, 103 insertions(+), 44 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 51c47f8..4832b40 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,23 +1,23 @@ - - true - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - \ No newline at end of file + + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs b/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs index 73fca06..d3bda5d 100644 --- a/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs +++ b/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs @@ -15,26 +15,55 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: table => new { Id = table.Column(type: "uniqueidentifier", nullable: false), - FirstName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - LastName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Email = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - PhoneNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), - DeletedOnUtc = table.Column(type: "datetime2", nullable: true), - Deleted = table.Column(type: "bit", nullable: false, defaultValue: false), - CreatedOnUtc = table.Column(type: "datetime2", nullable: false), - ModifiedOnUtc = table.Column(type: "datetime2", nullable: true) + FirstName = table.Column( + type: "nvarchar(100)", + maxLength: 100, + nullable: false + ), + LastName = table.Column( + type: "nvarchar(100)", + maxLength: 100, + nullable: false + ), + Email = table.Column( + type: "nvarchar(100)", + maxLength: 100, + nullable: false + ), + PhoneNumber = table.Column( + type: "nvarchar(20)", + maxLength: 20, + nullable: false + ), + DeletedOnUtc = table.Column( + type: "datetime2", + nullable: true + ), + Deleted = table.Column( + type: "bit", + nullable: false, + defaultValue: false + ), + CreatedOnUtc = table.Column( + type: "datetime2", + nullable: false + ), + ModifiedOnUtc = table.Column( + type: "datetime2", + nullable: true + ), }, constraints: table => { table.PrimaryKey("PK_Contacts", x => x.Id); - }); + } + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "Contacts"); + migrationBuilder.DropTable(name: "Contacts"); } } } diff --git a/src/PhoneForge.Persistence/PhoneForgeDbContext.cs b/src/PhoneForge.Persistence/PhoneForgeDbContext.cs index 7ba9647..d8e8811 100644 --- a/src/PhoneForge.Persistence/PhoneForgeDbContext.cs +++ b/src/PhoneForge.Persistence/PhoneForgeDbContext.cs @@ -18,7 +18,10 @@ public sealed class PhoneForgeDbContext : DbContext, IDbContext /// /// The database context options. /// The current date and time in UTC format. - public PhoneForgeDbContext(DbContextOptions options, IDateTimeProvider dateTimeProvider) + public PhoneForgeDbContext( + DbContextOptions options, + IDateTimeProvider dateTimeProvider + ) : base(options) { _dateTimeProvider = dateTimeProvider; @@ -30,11 +33,15 @@ public PhoneForgeDbContext(DbContextOptions options, IDateT /// protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.ApplyConfigurationsFromAssembly(typeof(PhoneForgeDbContext).Assembly); + modelBuilder.ApplyConfigurationsFromAssembly( + typeof(PhoneForgeDbContext).Assembly + ); } /// - public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + public override async Task SaveChangesAsync( + CancellationToken cancellationToken = default + ) { var utcNow = _dateTimeProvider.UtcNow; @@ -57,12 +64,14 @@ private void UpdateAuditableEntities(DateTime utcNow) { if (entity.State == EntityState.Added) { - entity.Property(nameof(IAuditableEntity.CreatedOnUtc)).CurrentValue = utcNow; + entity.Property(nameof(IAuditableEntity.CreatedOnUtc)).CurrentValue = + utcNow; } if (entity.State == EntityState.Modified) { - entity.Property(nameof(IAuditableEntity.ModifiedOnUtc)).CurrentValue = utcNow; + entity.Property(nameof(IAuditableEntity.ModifiedOnUtc)).CurrentValue = + utcNow; } } } @@ -80,7 +89,8 @@ private void UpdateSoftDeletableEntities(DateTime utcNow) foreach (var entity in entities) { - entity.Property(nameof(ISoftDeletableEntity.DeletedOnUtc)).CurrentValue = utcNow; + entity.Property(nameof(ISoftDeletableEntity.DeletedOnUtc)).CurrentValue = + utcNow; entity.Property(nameof(ISoftDeletableEntity.Deleted)).CurrentValue = true; diff --git a/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs b/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs index 4159f11..675cd4a 100644 --- a/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs +++ b/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs @@ -7,4 +7,9 @@ namespace PhoneForge.UseCases.Contacts.Create; /// The last name of the contact. /// The email of the contact. /// The phone number of the contact. -public sealed record CreateContactCommand(string FirstName, string LastName, string Email, string PhoneNumber); +public sealed record CreateContactCommand( + string FirstName, + string LastName, + string Email, + string PhoneNumber +); diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs index 045fc8c..35a7a98 100644 --- a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs +++ b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs @@ -7,4 +7,9 @@ namespace PhoneForge.WebApi.Endpoints.V1.Contacts.Create; /// The last name of the contact. /// The email of the contact. /// The phone number of the contact. -public sealed record CreateContactRequest(string FirstName, string LastName, string Email, string PhoneNumber); +public sealed record CreateContactRequest( + string FirstName, + string LastName, + string Email, + string PhoneNumber +); diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs index d17ffe7..d8b5a34 100644 --- a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs +++ b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs @@ -15,6 +15,8 @@ public CreateContactRequestValidator() RuleFor(x => x.FirstName).NotEmpty().WithMessage("The first name is required."); RuleFor(x => x.LastName).NotEmpty().WithMessage("The last name is required."); RuleFor(x => x.Email).NotEmpty().WithMessage("The email is required."); - RuleFor(x => x.PhoneNumber).NotEmpty().WithMessage("The phone number is required."); + RuleFor(x => x.PhoneNumber) + .NotEmpty() + .WithMessage("The phone number is required."); } } diff --git a/src/SharedKernel/Error.cs b/src/SharedKernel/Error.cs index 950bcac..2887a70 100644 --- a/src/SharedKernel/Error.cs +++ b/src/SharedKernel/Error.cs @@ -8,7 +8,11 @@ public sealed record Error /// /// Gets the empty error instance. /// - public static readonly Error None = new(string.Empty, string.Empty, ErrorType.Failure); + public static readonly Error None = new( + string.Empty, + string.Empty, + ErrorType.Failure + ); /// /// Initializes a new instance of the class. diff --git a/src/SharedKernel/ResultT.cs b/src/SharedKernel/ResultT.cs index a05c779..44499fc 100644 --- a/src/SharedKernel/ResultT.cs +++ b/src/SharedKernel/ResultT.cs @@ -26,7 +26,11 @@ public Result(TValue? value, bool isSuccess, Error error) /// The result value if the result is successful. /// when is true. public TValue Value => - IsSuccess ? _value! : throw new InvalidOperationException("The value of a failure result can't be accessed."); + IsSuccess + ? _value! + : throw new InvalidOperationException( + "The value of a failure result can't be accessed." + ); /// /// Implicitly converts a value of type From 0eb229a84eb615a7c4a2c5ab088fe15a403fd6f8 Mon Sep 17 00:00:00 2001 From: nwdorian Date: Sun, 16 Nov 2025 23:15:06 +0100 Subject: [PATCH 06/46] remove referenced .Contracts project and SwaggerUI ApiExplorer method --- src/PhoneForge.UseCases/PhoneForge.UseCases.csproj | 1 - src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj b/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj index 5d5885b..e4f6fed 100644 --- a/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj +++ b/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj @@ -3,7 +3,6 @@ True - diff --git a/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs b/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs index 99c1ec3..06b26d0 100644 --- a/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -36,7 +36,6 @@ IConfiguration configuration private static void AddPresentation(this IServiceCollection services) { - services.AddEndpointsApiExplorer(); services.AddOpenApi(); } From 901563f0d8808b3d294e2f41b156b910eb354b03 Mon Sep 17 00:00:00 2001 From: nwdorian Date: Mon, 17 Nov 2025 00:16:59 +0100 Subject: [PATCH 07/46] add Serilog logging --- Directory.Packages.props | 47 ++++++++++--------- .../Contacts/Create/CreateContact.cs | 11 ++++- .../ApplicationBuilderExtensions.cs | 31 ++++++++++++ .../Extensions/MiddlewareExtensions.cs | 10 ++++ .../Middleware/RequestLogContextMiddleware.cs | 35 ++++++++++++++ .../PhoneForge.WebApi.csproj | 3 ++ src/PhoneForge.WebApi/Program.cs | 1 + .../appsettings.Development.json | 23 +++++++++ src/PhoneForge.WebApi/appsettings.json | 28 +++++++++-- 9 files changed, 162 insertions(+), 27 deletions(-) create mode 100644 src/PhoneForge.WebApi/Extensions/ApplicationBuilderExtensions.cs create mode 100644 src/PhoneForge.WebApi/Middleware/RequestLogContextMiddleware.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 4832b40..79b8f6d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,23 +1,26 @@ - - true - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - + + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + \ No newline at end of file diff --git a/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs b/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs index 0b5b169..2988079 100644 --- a/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs +++ b/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PhoneForge.Domain.Contacts; using PhoneForge.UseCases.Abstractions.Data; using SharedKernel; @@ -11,14 +12,17 @@ namespace PhoneForge.UseCases.Contacts.Create; public sealed class CreateContact { private readonly IDbContext _context; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The database context used to persist the new contact. - public CreateContact(IDbContext context) + /// The logger used to record diagnostic information + public CreateContact(IDbContext context, ILogger logger) { _context = context; + _logger = logger; } /// @@ -30,6 +34,8 @@ public async Task> Handle( CancellationToken cancellationToken ) { + _logger.LogInformation("Processing request {Request}", request); + var firstNameResult = FirstName.Create(request.FirstName); var lastNameResult = LastName.Create(request.LastName); var emailResult = Email.Create(request.Email); @@ -44,6 +50,7 @@ CancellationToken cancellationToken if (firstFailOrSuccess.IsFailure) { + _logger.LogWarning("Error: {@Error}", firstFailOrSuccess.Error); return firstFailOrSuccess.Error; } @@ -54,6 +61,7 @@ await _context.Contacts.AnyAsync( ) ) { + _logger.LogWarning("Error: {@Error}", ContactErrors.EmailNotUnique); return ContactErrors.EmailNotUnique; } @@ -78,6 +86,7 @@ await _context.Contacts.AnyAsync( contact.CreatedOnUtc ); + _logger.LogInformation("Completed request {@Request}", request); return response; } } diff --git a/src/PhoneForge.WebApi/Extensions/ApplicationBuilderExtensions.cs b/src/PhoneForge.WebApi/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..0ff3f31 --- /dev/null +++ b/src/PhoneForge.WebApi/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,31 @@ +using Serilog; + +namespace PhoneForge.WebApi.Extensions; + +/// +/// Provides extension methods for configuring Serilog in the application. +/// +public static class ApplicationBuilderExtensions +{ + /// + /// Configures Serilog as the logging provider for the application using the settings + /// specified in the application's configuration. + /// + /// The application builder used to configure the host. + /// + /// The same instance, allowing for method chaining. + /// + public static WebApplicationBuilder ConfigureSerilog( + this WebApplicationBuilder builder + ) + { + builder.Host.UseSerilog( + (context, config) => + { + config.ReadFrom.Configuration(context.Configuration); + } + ); + + return builder; + } +} diff --git a/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs b/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs index 8974bbd..b64e826 100644 --- a/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs +++ b/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs @@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore; using PhoneForge.Persistence; using PhoneForge.WebApi.Endpoints.V1.Contacts; +using PhoneForge.WebApi.Middleware; +using Serilog; namespace PhoneForge.WebApi.Extensions; @@ -24,6 +26,9 @@ this WebApplication app app.UseOpenApi(); app.UseHttpsRedirection(); + app.UseRequestContextLogging(); + app.UseSerilogRequestLogging(); + await app.ApplyMigrations(); return app; @@ -50,6 +55,11 @@ private static void UseOpenApi(this WebApplication app) } } + private static void UseRequestContextLogging(this WebApplication app) + { + app.UseMiddleware(); + } + private static async Task ApplyMigrations(this IApplicationBuilder app) { using var scope = app.ApplicationServices.CreateScope(); diff --git a/src/PhoneForge.WebApi/Middleware/RequestLogContextMiddleware.cs b/src/PhoneForge.WebApi/Middleware/RequestLogContextMiddleware.cs new file mode 100644 index 0000000..9352d88 --- /dev/null +++ b/src/PhoneForge.WebApi/Middleware/RequestLogContextMiddleware.cs @@ -0,0 +1,35 @@ +using Serilog.Context; + +namespace PhoneForge.WebApi.Middleware; + +/// +/// Middleware that enriches the logging context with a correlation ID +/// for each incoming HTTP request. +/// +public class RequestLogContextMiddleware +{ + private readonly RequestDelegate _next; + + /// + /// Initializes a new instance of the class. + /// + /// The next middleware in the request pipeline. + public RequestLogContextMiddleware(RequestDelegate next) + { + _next = next; + } + + /// + /// Adds the current request's correlation ID to the Serilog log context + /// and invokes the next middleware in the pipeline. + /// + /// The HTTP context for the current request. + /// A task representing the asynchronous middleware operation. + public Task InvokeAsync(HttpContext context) + { + using (LogContext.PushProperty("CorrelationId", context.TraceIdentifier)) + { + return _next(context); + } + } +} diff --git a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj b/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj index bba13cb..9117825 100644 --- a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj +++ b/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj @@ -11,6 +11,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + diff --git a/src/PhoneForge.WebApi/Program.cs b/src/PhoneForge.WebApi/Program.cs index 2c459e4..b09e6b0 100644 --- a/src/PhoneForge.WebApi/Program.cs +++ b/src/PhoneForge.WebApi/Program.cs @@ -2,6 +2,7 @@ var builder = WebApplication.CreateBuilder(args); +builder.ConfigureSerilog(); builder.Services.AddWebApplicationServices(builder.Configuration); var app = builder.Build(); diff --git a/src/PhoneForge.WebApi/appsettings.Development.json b/src/PhoneForge.WebApi/appsettings.Development.json index ed57909..faf0bf4 100644 --- a/src/PhoneForge.WebApi/appsettings.Development.json +++ b/src/PhoneForge.WebApi/appsettings.Development.json @@ -1,5 +1,28 @@ { "ConnectionStrings": { "PhoneForgeDb": "Data Source=(localdb)\\MSSQLLocalDB; Initial Catalog=PhoneForgeDb; Integrated Security=SSPI" + }, + "Serilog":{ + "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Information", + "System": "Information" + } + }, + "WriteTo":[ + {"Name": "Console"}, + { + "Name": "File", + "Args": { + "path": "../../logs/log-.txt", + "rollingInterval": "Day", + "rollOnFileSizeLimit": true, + "formatter": "Serilog.Formatting.Json.JsonFormatter" + } + } + ], + "Enrich": [ "FromLogContext" ] } } diff --git a/src/PhoneForge.WebApi/appsettings.json b/src/PhoneForge.WebApi/appsettings.json index 10f68b8..6799e37 100644 --- a/src/PhoneForge.WebApi/appsettings.json +++ b/src/PhoneForge.WebApi/appsettings.json @@ -1,9 +1,29 @@ { - "Logging": { - "LogLevel": { + "ConnectionStrings": { + "PhoneForgeDb": "" + }, + "Serilog":{ + "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"], + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo":[ + {"Name": "Console"}, + { + "Name": "File", + "Args": { + "path": "../../logs/log-.txt", + "rollingInterval": "Day", + "rollOnFileSizeLimit": true, + "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" + } + } + ], + "Enrich": [ "FromLogContext" ] }, "AllowedHosts": "*" } From 06aa236fe98e3609126fd1b500e92882b358d72e Mon Sep 17 00:00:00 2001 From: nwdorian Date: Mon, 17 Nov 2025 15:50:38 +0100 Subject: [PATCH 08/46] add global exception handler --- .../Extensions/MiddlewareExtensions.cs | 2 + .../Extensions/ServiceCollectionExtensions.cs | 18 ++++ .../Infrastructure/GlobalExceptionHandler.cs | 85 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/PhoneForge.WebApi/Infrastructure/GlobalExceptionHandler.cs diff --git a/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs b/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs index b64e826..48240bd 100644 --- a/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs +++ b/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs @@ -29,6 +29,8 @@ this WebApplication app app.UseRequestContextLogging(); app.UseSerilogRequestLogging(); + app.UseExceptionHandler(); + await app.ApplyMigrations(); return app; diff --git a/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs b/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs index 06b26d0..480b282 100644 --- a/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using PhoneForge.Infrastructure; using PhoneForge.Persistence; using PhoneForge.UseCases; +using PhoneForge.WebApi.Infrastructure; namespace PhoneForge.WebApi.Extensions; @@ -31,6 +32,8 @@ IConfiguration configuration services.AddApiVersioning(); + services.AddCustomExceptionHandler(); + return services; } @@ -59,4 +62,19 @@ private static void AddApiVersioning(this IServiceCollection services) options.SubstituteApiVersionInUrl = true; }); } + + private static void AddCustomExceptionHandler(this IServiceCollection services) + { + services.AddExceptionHandler(); + services.AddProblemDetails(configure => + { + configure.CustomizeProblemDetails = context => + { + context.ProblemDetails.Extensions.TryAdd( + "requestId", + context.HttpContext.TraceIdentifier + ); + }; + }); + } } diff --git a/src/PhoneForge.WebApi/Infrastructure/GlobalExceptionHandler.cs b/src/PhoneForge.WebApi/Infrastructure/GlobalExceptionHandler.cs new file mode 100644 index 0000000..0140326 --- /dev/null +++ b/src/PhoneForge.WebApi/Infrastructure/GlobalExceptionHandler.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace PhoneForge.WebApi.Infrastructure; + +/// +/// A global exception handler that captures unhandled exceptions and converts them +/// into standardized responses. +/// +public sealed class GlobalExceptionHandler : IExceptionHandler +{ + private readonly IProblemDetailsService _problemDetailsService; + private readonly ILogger _logger; + private readonly IHostEnvironment _environment; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The service responsible for writing responses. + /// + /// The logger used to record exception details. + /// Provides information about the current hosting environment. + public GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger, + IHostEnvironment environment + ) + { + _problemDetailsService = problemDetailsService; + _logger = logger; + _environment = environment; + } + + /// + /// Attempts to handle an unhandled exception by logging it and returning a standardized + /// response to the client. + /// + /// The current HTTP context. + /// The exception that occurred. + /// A token used to cancel the operation. + /// + /// true if the exception was successfully written as a problem details response; + /// otherwise, false. + /// + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken + ) + { + _logger.LogError(exception, "Unhandled exception occurred"); + + return await _problemDetailsService.TryWriteAsync( + new ProblemDetailsContext + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Type = exception.GetType().Name, + Title = "An error occurred", + Detail = GetErrorDetail(exception), + }, + } + ); + } + + /// + /// Returns an error message appropriate for the current hosting environment. + /// In development, the exception message is returned. In production, a generic + /// error message is used to avoid leaking internal details. + /// + /// The exception to extract details from. + /// + /// A detailed message in development, or a safe generic message in non-development environments. + /// + private string GetErrorDetail(Exception exception) + { + return _environment.IsDevelopment() + ? exception.Message + : "Something went wrong. Please try again later."; + } +} From 05d7da47bc3979d0a13c461a7ae743a57a20df68 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sat, 22 Nov 2025 02:32:33 +0100 Subject: [PATCH 09/46] refactor: vertical slices (#7) * refactor vertical slices * move Request types into the UseCases layer * add sealed modifier to Validator class and fix logging typos * add IUseCase interface and setup DI * add XML comments * rename CreateContactEndpoint to Create * remove V1 folder * flatten project structure * remove solution file * restructure folders * change folder structure * organize into Core folder per project * add WithNameAndTags extension method * modify the endpoint logging message * refactor to Command objects and move requests to API layer * remove exception details from GlobalExceptionHandler * remnove Ensure helpers --- Directory.Packages.props | 50 +++++----- PhoneForge.sln | 99 +++++++++---------- .../Application/Application.csproj | 2 +- .../Contacts/Create/CreateContact.cs | 37 ++++--- .../Contacts/Create/CreateContactCommand.cs | 6 +- .../Contacts/Create/CreateContactResponse.cs | 6 +- .../Core}/Abstractions/Data/IDbContext.cs | 4 +- .../Application/Core/Abstractions/IUseCase.cs | 7 ++ .../Application/Core}/DependencyInjection.cs | 22 ++++- .../Domain}/Contacts/Contact.cs | 10 +- .../Domain}/Contacts/ContactErrors.cs | 4 +- .../Domain}/Contacts/Email.cs | 4 +- .../Domain}/Contacts/FirstName.cs | 4 +- .../Domain}/Contacts/LastName.cs | 4 +- .../Domain}/Contacts/PhoneNumber.cs | 4 +- .../Core/Abstractions}/IAuditableEntity.cs | 2 +- .../Core/Abstractions}/IDateTimeProvider.cs | 2 +- .../Abstractions}/ISoftDeletableEntity.cs | 2 +- .../Domain/Core/Primitives}/Entity.cs | 2 +- .../Domain/Core/Primitives}/Error.cs | 2 +- .../Domain/Core/Primitives}/ErrorType.cs | 2 +- .../Domain/Core/Primitives}/Result.cs | 2 +- .../Domain/Core/Primitives}/ResultT.cs | 2 +- .../Domain/Domain.csproj | 2 +- .../Core}/DependencyInjection.cs | 30 ++++-- .../Core}/Time/DateTimeProvider.cs | 4 +- .../Configurations/ContactConfiguration.cs | 4 +- .../20251115144452_InitialCreate.Designer.cs | 5 +- .../20251115144452_InitialCreate.cs | 6 +- .../PhoneForgeDbContextModelSnapshot.cs | 4 +- .../Database}/PhoneForgeDbContext.cs | 8 +- .../Infrastructure/Infrastructure.csproj | 2 +- .../Contacts/Create/CreateContactEndpoint.cs | 37 +++++++ .../Contacts/Create/CreateContactRequest.cs | 4 +- .../Create/CreateContactRequestValidator.cs | 32 ++++++ .../ApplicationBuilderExtensions.cs | 2 +- .../Core/Extensions/EndpointExtensions.cs | 95 ++++++++++++++++++ .../Core}/Extensions/MiddlewareExtensions.cs | 42 ++++---- .../Core}/Extensions/ResultExtensions.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 36 ++++--- .../WebApi/Core}/HttpFiles/Contacts.http | 2 +- backend/WebApi/Core/IEndpoint.cs | 15 +++ .../Core}/Infrastructure/CustomResults.cs | 18 +--- .../Infrastructure/GlobalExceptionHandler.cs | 29 +----- .../Core/Infrastructure/ValidationFilter.cs | 51 ++++++++++ .../Middleware/RequestLogContextMiddleware.cs | 2 +- .../WebApi/Core}/Routes.cs | 7 +- backend/WebApi/Core/Tags.cs | 12 +++ .../WebApi}/Program.cs | 2 +- .../WebApi}/Properties/launchSettings.json | 0 .../WebApi/WebApi.csproj | 5 +- .../WebApi}/appsettings.Development.json | 3 +- .../WebApi}/appsettings.json | 0 .../Console/Console.csproj | 0 .../Console}/Program.cs | 0 .../DependencyInjection.cs | 25 ----- .../PhoneForge.Infrastructure.csproj | 8 -- .../Contacts/ContactsEndpointsExtensions.cs | 18 ---- .../Contacts/Create/CreateContactEndpoint.cs | 44 --------- .../Create/CreateContactRequestValidator.cs | 22 ----- src/SharedKernel/Ensure.cs | 69 ------------- src/SharedKernel/SharedKernel.csproj | 5 - .../UnitTests.csproj} | 0 63 files changed, 491 insertions(+), 442 deletions(-) rename src/PhoneForge.UseCases/PhoneForge.UseCases.csproj => backend/Application/Application.csproj (77%) rename {src/PhoneForge.UseCases => backend/Application}/Contacts/Create/CreateContact.cs (68%) rename {src/PhoneForge.UseCases => backend/Application}/Contacts/Create/CreateContactCommand.cs (72%) rename {src/PhoneForge.UseCases => backend/Application}/Contacts/Create/CreateContactResponse.cs (73%) rename {src/PhoneForge.UseCases => backend/Application/Core}/Abstractions/Data/IDbContext.cs (88%) create mode 100644 backend/Application/Core/Abstractions/IUseCase.cs rename {src/PhoneForge.UseCases => backend/Application/Core}/DependencyInjection.cs (51%) rename {src/PhoneForge.Domain => backend/Domain}/Contacts/Contact.cs (87%) rename {src/PhoneForge.Domain => backend/Domain}/Contacts/ContactErrors.cs (98%) rename {src/PhoneForge.Domain => backend/Domain}/Contacts/Email.cs (97%) rename {src/PhoneForge.Domain => backend/Domain}/Contacts/FirstName.cs (96%) rename {src/PhoneForge.Domain => backend/Domain}/Contacts/LastName.cs (96%) rename {src/PhoneForge.Domain => backend/Domain}/Contacts/PhoneNumber.cs (97%) rename {src/SharedKernel => backend/Domain/Core/Abstractions}/IAuditableEntity.cs (91%) rename {src/SharedKernel => backend/Domain/Core/Abstractions}/IDateTimeProvider.cs (87%) rename {src/SharedKernel => backend/Domain/Core/Abstractions}/ISoftDeletableEntity.cs (92%) rename {src/SharedKernel => backend/Domain/Core/Primitives}/Entity.cs (96%) rename {src/SharedKernel => backend/Domain/Core/Primitives}/Error.cs (98%) rename {src/SharedKernel => backend/Domain/Core/Primitives}/ErrorType.cs (95%) rename {src/SharedKernel => backend/Domain/Core/Primitives}/Result.cs (99%) rename {src/SharedKernel => backend/Domain/Core/Primitives}/ResultT.cs (98%) rename src/PhoneForge.Domain/PhoneForge.Domain.csproj => backend/Domain/Domain.csproj (71%) rename {src/PhoneForge.Persistence => backend/Infrastructure/Core}/DependencyInjection.cs (50%) rename {src/PhoneForge.Infrastructure => backend/Infrastructure/Core}/Time/DateTimeProvider.cs (75%) rename {src/PhoneForge.Persistence => backend/Infrastructure/Database}/Configurations/ContactConfiguration.cs (96%) rename {src/PhoneForge.Persistence => backend/Infrastructure/Database}/Migrations/20251115144452_InitialCreate.Designer.cs (98%) rename {src/PhoneForge.Persistence => backend/Infrastructure/Database}/Migrations/20251115144452_InitialCreate.cs (95%) rename {src/PhoneForge.Persistence => backend/Infrastructure/Database}/Migrations/PhoneForgeDbContextModelSnapshot.cs (98%) rename {src/PhoneForge.Persistence => backend/Infrastructure/Database}/PhoneForgeDbContext.cs (96%) rename src/PhoneForge.Persistence/PhoneForge.Persistence.csproj => backend/Infrastructure/Infrastructure.csproj (77%) create mode 100644 backend/WebApi/Contacts/Create/CreateContactEndpoint.cs rename {src/PhoneForge.WebApi/Endpoints/V1 => backend/WebApi}/Contacts/Create/CreateContactRequest.cs (79%) create mode 100644 backend/WebApi/Contacts/Create/CreateContactRequestValidator.cs rename {src/PhoneForge.WebApi => backend/WebApi/Core}/Extensions/ApplicationBuilderExtensions.cs (95%) create mode 100644 backend/WebApi/Core/Extensions/EndpointExtensions.cs rename {src/PhoneForge.WebApi => backend/WebApi/Core}/Extensions/MiddlewareExtensions.cs (66%) rename {src/PhoneForge.WebApi => backend/WebApi/Core}/Extensions/ResultExtensions.cs (96%) rename {src/PhoneForge.WebApi => backend/WebApi/Core}/Extensions/ServiceCollectionExtensions.cs (80%) rename {src/PhoneForge.WebApi => backend/WebApi/Core}/HttpFiles/Contacts.http (88%) create mode 100644 backend/WebApi/Core/IEndpoint.cs rename {src/PhoneForge.WebApi => backend/WebApi/Core}/Infrastructure/CustomResults.cs (83%) rename {src/PhoneForge.WebApi => backend/WebApi/Core}/Infrastructure/GlobalExceptionHandler.cs (65%) create mode 100644 backend/WebApi/Core/Infrastructure/ValidationFilter.cs rename {src/PhoneForge.WebApi => backend/WebApi/Core}/Middleware/RequestLogContextMiddleware.cs (96%) rename {src/PhoneForge.WebApi/Endpoints => backend/WebApi/Core}/Routes.cs (74%) create mode 100644 backend/WebApi/Core/Tags.cs rename {src/PhoneForge.WebApi => backend/WebApi}/Program.cs (86%) rename {src/PhoneForge.WebApi => backend/WebApi}/Properties/launchSettings.json (100%) rename src/PhoneForge.WebApi/PhoneForge.WebApi.csproj => backend/WebApi/WebApi.csproj (82%) rename {src/PhoneForge.WebApi => backend/WebApi}/appsettings.Development.json (87%) rename {src/PhoneForge.WebApi => backend/WebApi}/appsettings.json (100%) rename src/PhoneForge.Console/PhoneForge.Console.csproj => frontend/Console/Console.csproj (100%) rename {src/PhoneForge.Console => frontend/Console}/Program.cs (100%) delete mode 100644 src/PhoneForge.Infrastructure/DependencyInjection.cs delete mode 100644 src/PhoneForge.Infrastructure/PhoneForge.Infrastructure.csproj delete mode 100644 src/PhoneForge.WebApi/Endpoints/V1/Contacts/ContactsEndpointsExtensions.cs delete mode 100644 src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactEndpoint.cs delete mode 100644 src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs delete mode 100644 src/SharedKernel/Ensure.cs delete mode 100644 src/SharedKernel/SharedKernel.csproj rename tests/{PhoneForge.UnitTests/PhoneForge.UnitTests.csproj => UnitTests/UnitTests.csproj} (100%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 79b8f6d..6a13dd9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,26 +1,26 @@ - - true - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - \ No newline at end of file + + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/PhoneForge.sln b/PhoneForge.sln index 67169f9..a307a7a 100644 --- a/PhoneForge.sln +++ b/PhoneForge.sln @@ -1,33 +1,31 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B28046D9-09AF-4324-BACA-938288D63BB8}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.WebApi", "src\PhoneForge.WebApi\PhoneForge.WebApi.csproj", "{C6075A87-33DD-4978-9093-D58857FB2C73}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "frontend", "frontend", "{CE609670-85B9-0D16-FB54-ED063D5D8A6D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.Domain", "src\PhoneForge.Domain\PhoneForge.Domain.csproj", "{7A1486F7-1130-401A-9897-2DBA609F766F}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.UseCases", "src\PhoneForge.UseCases\PhoneForge.UseCases.csproj", "{C6A62053-9A13-40A6-870B-C2F54A40301D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "backend\Application\Application.csproj", "{09B42AD5-BBE1-572C-B555-FF85175AD7CA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.Persistence", "src\PhoneForge.Persistence\PhoneForge.Persistence.csproj", "{FF2C0AF1-64E2-4C0A-8716-B6868FE01B3F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "backend\Domain\Domain.csproj", "{1AF93A02-C695-A740-C744-9453045209F4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.Infrastructure", "src\PhoneForge.Infrastructure\PhoneForge.Infrastructure.csproj", "{6FCDD768-EB98-42DA-B5CA-D92072E6AD66}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "backend\Infrastructure\Infrastructure.csproj", "{BAC07D44-E017-A073-6C9A-C5B760BE66C1}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9BA151AB-1A46-4852-9403-A041A84D4CC7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi", "backend\WebApi\WebApi.csproj", "{FF832594-E403-2B1F-49E8-55A6A5C58268}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.UnitTests", "tests\PhoneForge.UnitTests\PhoneForge.UnitTests.csproj", "{18E039F8-BFDD-4DC2-B973-2F90371D3939}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Console", "frontend\Console\Console.csproj", "{15D2C271-E237-D10C-AFFC-2FB01BAF09C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneForge.Console", "src\PhoneForge.Console\PhoneForge.Console.csproj", "{44808F61-EF0E-4854-90A7-2554235FDAD9}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedKernel", "src\SharedKernel\SharedKernel.csproj", "{B9C5B96B-5763-47D3-ADDE-F840127A53C4}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{9D1DD2CD-7B04-4472-4377-027563F356CA}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{089100B1-113F-4E66-888A-E83F3999EAFD}" ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig Directory.Build.Props = Directory.Build.Props Directory.Packages.props = Directory.Packages.props + PhoneForge.sln = PhoneForge.sln + .editorconfig = .editorconfig EndProjectSection EndProject Global @@ -36,50 +34,43 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C6075A87-33DD-4978-9093-D58857FB2C73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C6075A87-33DD-4978-9093-D58857FB2C73}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C6075A87-33DD-4978-9093-D58857FB2C73}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C6075A87-33DD-4978-9093-D58857FB2C73}.Release|Any CPU.Build.0 = Release|Any CPU - {7A1486F7-1130-401A-9897-2DBA609F766F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7A1486F7-1130-401A-9897-2DBA609F766F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7A1486F7-1130-401A-9897-2DBA609F766F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7A1486F7-1130-401A-9897-2DBA609F766F}.Release|Any CPU.Build.0 = Release|Any CPU - {C6A62053-9A13-40A6-870B-C2F54A40301D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C6A62053-9A13-40A6-870B-C2F54A40301D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C6A62053-9A13-40A6-870B-C2F54A40301D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C6A62053-9A13-40A6-870B-C2F54A40301D}.Release|Any CPU.Build.0 = Release|Any CPU - {FF2C0AF1-64E2-4C0A-8716-B6868FE01B3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FF2C0AF1-64E2-4C0A-8716-B6868FE01B3F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FF2C0AF1-64E2-4C0A-8716-B6868FE01B3F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FF2C0AF1-64E2-4C0A-8716-B6868FE01B3F}.Release|Any CPU.Build.0 = Release|Any CPU - {6FCDD768-EB98-42DA-B5CA-D92072E6AD66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6FCDD768-EB98-42DA-B5CA-D92072E6AD66}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6FCDD768-EB98-42DA-B5CA-D92072E6AD66}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6FCDD768-EB98-42DA-B5CA-D92072E6AD66}.Release|Any CPU.Build.0 = Release|Any CPU - {18E039F8-BFDD-4DC2-B973-2F90371D3939}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {18E039F8-BFDD-4DC2-B973-2F90371D3939}.Debug|Any CPU.Build.0 = Debug|Any CPU - {18E039F8-BFDD-4DC2-B973-2F90371D3939}.Release|Any CPU.ActiveCfg = Release|Any CPU - {18E039F8-BFDD-4DC2-B973-2F90371D3939}.Release|Any CPU.Build.0 = Release|Any CPU - {44808F61-EF0E-4854-90A7-2554235FDAD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {44808F61-EF0E-4854-90A7-2554235FDAD9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {44808F61-EF0E-4854-90A7-2554235FDAD9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {44808F61-EF0E-4854-90A7-2554235FDAD9}.Release|Any CPU.Build.0 = Release|Any CPU - {B9C5B96B-5763-47D3-ADDE-F840127A53C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9C5B96B-5763-47D3-ADDE-F840127A53C4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9C5B96B-5763-47D3-ADDE-F840127A53C4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9C5B96B-5763-47D3-ADDE-F840127A53C4}.Release|Any CPU.Build.0 = Release|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Release|Any CPU.Build.0 = Release|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Release|Any CPU.Build.0 = Release|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Release|Any CPU.Build.0 = Release|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Release|Any CPU.Build.0 = Release|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|Any CPU.Build.0 = Release|Any CPU + {9D1DD2CD-7B04-4472-4377-027563F356CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D1DD2CD-7B04-4472-4377-027563F356CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D1DD2CD-7B04-4472-4377-027563F356CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D1DD2CD-7B04-4472-4377-027563F356CA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {C6075A87-33DD-4978-9093-D58857FB2C73} = {B28046D9-09AF-4324-BACA-938288D63BB8} - {7A1486F7-1130-401A-9897-2DBA609F766F} = {B28046D9-09AF-4324-BACA-938288D63BB8} - {C6A62053-9A13-40A6-870B-C2F54A40301D} = {B28046D9-09AF-4324-BACA-938288D63BB8} - {FF2C0AF1-64E2-4C0A-8716-B6868FE01B3F} = {B28046D9-09AF-4324-BACA-938288D63BB8} - {6FCDD768-EB98-42DA-B5CA-D92072E6AD66} = {B28046D9-09AF-4324-BACA-938288D63BB8} - {18E039F8-BFDD-4DC2-B973-2F90371D3939} = {9BA151AB-1A46-4852-9403-A041A84D4CC7} - {44808F61-EF0E-4854-90A7-2554235FDAD9} = {B28046D9-09AF-4324-BACA-938288D63BB8} - {B9C5B96B-5763-47D3-ADDE-F840127A53C4} = {B28046D9-09AF-4324-BACA-938288D63BB8} + {09B42AD5-BBE1-572C-B555-FF85175AD7CA} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {1AF93A02-C695-A740-C744-9453045209F4} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {BAC07D44-E017-A073-6C9A-C5B760BE66C1} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {FF832594-E403-2B1F-49E8-55A6A5C58268} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3} = {CE609670-85B9-0D16-FB54-ED063D5D8A6D} + {9D1DD2CD-7B04-4472-4377-027563F356CA} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {646D0DF0-B048-4BFA-9672-6C5C78680E95} EndGlobalSection EndGlobal diff --git a/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj b/backend/Application/Application.csproj similarity index 77% rename from src/PhoneForge.UseCases/PhoneForge.UseCases.csproj rename to backend/Application/Application.csproj index e4f6fed..6770921 100644 --- a/src/PhoneForge.UseCases/PhoneForge.UseCases.csproj +++ b/backend/Application/Application.csproj @@ -3,7 +3,7 @@ True - + diff --git a/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs b/backend/Application/Contacts/Create/CreateContact.cs similarity index 68% rename from src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs rename to backend/Application/Contacts/Create/CreateContact.cs index 2988079..b0a3733 100644 --- a/src/PhoneForge.UseCases/Contacts/Create/CreateContact.cs +++ b/backend/Application/Contacts/Create/CreateContact.cs @@ -1,15 +1,16 @@ +using Application.Core.Abstractions; +using Application.Core.Abstractions.Data; +using Domain.Contacts; +using Domain.Core.Primitives; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using PhoneForge.Domain.Contacts; -using PhoneForge.UseCases.Abstractions.Data; -using SharedKernel; -namespace PhoneForge.UseCases.Contacts.Create; +namespace Application.Contacts.Create; /// -/// Represents the handler. +/// Represents the use case. /// -public sealed class CreateContact +public sealed class CreateContact : IUseCase { private readonly IDbContext _context; private readonly ILogger _logger; @@ -18,7 +19,7 @@ public sealed class CreateContact /// Initializes a new instance of the class. /// /// The database context used to persist the new contact. - /// The logger used to record diagnostic information + /// The logger used to record diagnostic information. public CreateContact(IDbContext context, ILogger logger) { _context = context; @@ -26,20 +27,20 @@ public CreateContact(IDbContext context, ILogger logger) } /// - /// Handles a request. + /// Handles a . /// - /// Response from the request. + /// Response from the command. public async Task> Handle( - CreateContactCommand request, + CreateContactCommand command, CancellationToken cancellationToken ) { - _logger.LogInformation("Processing request {Request}", request); + _logger.LogInformation("Processing command {Command}", command); - var firstNameResult = FirstName.Create(request.FirstName); - var lastNameResult = LastName.Create(request.LastName); - var emailResult = Email.Create(request.Email); - var phoneNumberResult = PhoneNumber.Create(request.PhoneNumber); + var firstNameResult = FirstName.Create(command.FirstName); + var lastNameResult = LastName.Create(command.LastName); + var emailResult = Email.Create(command.Email); + var phoneNumberResult = PhoneNumber.Create(command.PhoneNumber); var firstFailOrSuccess = Result.FirstFailOrSuccess( firstNameResult, @@ -56,7 +57,7 @@ CancellationToken cancellationToken if ( await _context.Contacts.AnyAsync( - c => c.Email.Value == request.Email, + c => c.Email.Value == command.Email, cancellationToken ) ) @@ -78,15 +79,13 @@ await _context.Contacts.AnyAsync( var response = new CreateContactResponse( contact.Id, - contact.FirstName, - contact.LastName, contact.FullName, contact.Email, contact.PhoneNumber, contact.CreatedOnUtc ); - _logger.LogInformation("Completed request {@Request}", request); + _logger.LogInformation("Completed command {@Command}", command); return response; } } diff --git a/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs b/backend/Application/Contacts/Create/CreateContactCommand.cs similarity index 72% rename from src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs rename to backend/Application/Contacts/Create/CreateContactCommand.cs index 675cd4a..3b5a863 100644 --- a/src/PhoneForge.UseCases/Contacts/Create/CreateContactCommand.cs +++ b/backend/Application/Contacts/Create/CreateContactCommand.cs @@ -1,13 +1,13 @@ -namespace PhoneForge.UseCases.Contacts.Create; +namespace Application.Contacts.Create; /// -/// Represents a command for creating a new contact. +/// Represents the create contact command. /// /// The first name of the contact. /// The last name of the contact. /// The email of the contact. /// The phone number of the contact. -public sealed record CreateContactCommand( +public sealed record class CreateContactCommand( string FirstName, string LastName, string Email, diff --git a/src/PhoneForge.UseCases/Contacts/Create/CreateContactResponse.cs b/backend/Application/Contacts/Create/CreateContactResponse.cs similarity index 73% rename from src/PhoneForge.UseCases/Contacts/Create/CreateContactResponse.cs rename to backend/Application/Contacts/Create/CreateContactResponse.cs index 0f6828e..13a722b 100644 --- a/src/PhoneForge.UseCases/Contacts/Create/CreateContactResponse.cs +++ b/backend/Application/Contacts/Create/CreateContactResponse.cs @@ -1,19 +1,15 @@ -namespace PhoneForge.UseCases.Contacts.Create; +namespace Application.Contacts.Create; /// /// Represents the response returned after creating a new contact. /// /// The unique identifier of the contact. -/// The first name of the contact. -/// The last name of the contact. /// The full name of the contact. /// The email address of the contact. /// The phone number of the contact. /// The date and time when the contact was created in UTC format. public sealed record CreateContactResponse( Guid Id, - string FirstName, - string LastName, string FullName, string Email, string PhoneNumber, diff --git a/src/PhoneForge.UseCases/Abstractions/Data/IDbContext.cs b/backend/Application/Core/Abstractions/Data/IDbContext.cs similarity index 88% rename from src/PhoneForge.UseCases/Abstractions/Data/IDbContext.cs rename to backend/Application/Core/Abstractions/Data/IDbContext.cs index 32d5e43..8bfeed6 100644 --- a/src/PhoneForge.UseCases/Abstractions/Data/IDbContext.cs +++ b/backend/Application/Core/Abstractions/Data/IDbContext.cs @@ -1,7 +1,7 @@ +using Domain.Contacts; using Microsoft.EntityFrameworkCore; -using PhoneForge.Domain.Contacts; -namespace PhoneForge.UseCases.Abstractions.Data; +namespace Application.Core.Abstractions.Data; /// /// Represents the application database context interface. diff --git a/backend/Application/Core/Abstractions/IUseCase.cs b/backend/Application/Core/Abstractions/IUseCase.cs new file mode 100644 index 0000000..c650d12 --- /dev/null +++ b/backend/Application/Core/Abstractions/IUseCase.cs @@ -0,0 +1,7 @@ +namespace Application.Core.Abstractions; + +/// +/// Represents a use case marker interface. +/// Used with DI to register use case implementations. +/// +public interface IUseCase; diff --git a/src/PhoneForge.UseCases/DependencyInjection.cs b/backend/Application/Core/DependencyInjection.cs similarity index 51% rename from src/PhoneForge.UseCases/DependencyInjection.cs rename to backend/Application/Core/DependencyInjection.cs index c5bddb8..252324b 100644 --- a/src/PhoneForge.UseCases/DependencyInjection.cs +++ b/backend/Application/Core/DependencyInjection.cs @@ -1,7 +1,8 @@ +using System.Reflection; +using Application.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -using PhoneForge.UseCases.Contacts.Create; -namespace PhoneForge.UseCases; +namespace Application.Core; /// /// Provides extension methods for registering use case services. @@ -15,8 +16,23 @@ public static class DependencyInjection /// The same instance, allowing for method chaining. public static IServiceCollection AddUseCases(this IServiceCollection services) { - services.AddScoped(); + services.AddUseCases(typeof(IUseCase).Assembly); return services; } + + private static void AddUseCases(this IServiceCollection services, Assembly assembly) + { + var types = assembly + .DefinedTypes.Where(type => + type is { IsAbstract: false, IsInterface: false } + && type.IsAssignableTo(typeof(IUseCase)) + ) + .ToArray(); + + foreach (var type in types) + { + services.AddScoped(type, type); + } + } } diff --git a/src/PhoneForge.Domain/Contacts/Contact.cs b/backend/Domain/Contacts/Contact.cs similarity index 87% rename from src/PhoneForge.Domain/Contacts/Contact.cs rename to backend/Domain/Contacts/Contact.cs index 1ab757e..fdb37ab 100644 --- a/src/PhoneForge.Domain/Contacts/Contact.cs +++ b/backend/Domain/Contacts/Contact.cs @@ -1,6 +1,7 @@ -using SharedKernel; +using Domain.Core.Abstractions; +using Domain.Core.Primitives; -namespace PhoneForge.Domain.Contacts; +namespace Domain.Contacts; /// /// Represents the user entity. @@ -22,11 +23,6 @@ PhoneNumber phoneNumber ) : base(Guid.NewGuid()) { - Ensure.NotNull(firstName, "The first name is required", nameof(firstName)); - Ensure.NotNull(lastName, "The last name is required", nameof(lastName)); - Ensure.NotNull(email, "The email is required", nameof(email)); - Ensure.NotNull(phoneNumber, "The phone number is required", nameof(phoneNumber)); - FirstName = firstName; LastName = lastName; Email = email; diff --git a/src/PhoneForge.Domain/Contacts/ContactErrors.cs b/backend/Domain/Contacts/ContactErrors.cs similarity index 98% rename from src/PhoneForge.Domain/Contacts/ContactErrors.cs rename to backend/Domain/Contacts/ContactErrors.cs index e3f4926..b3a7f6b 100644 --- a/src/PhoneForge.Domain/Contacts/ContactErrors.cs +++ b/backend/Domain/Contacts/ContactErrors.cs @@ -1,6 +1,6 @@ -using SharedKernel; +using Domain.Core.Primitives; -namespace PhoneForge.Domain.Contacts; +namespace Domain.Contacts; /// /// Contains the contact errors. diff --git a/src/PhoneForge.Domain/Contacts/Email.cs b/backend/Domain/Contacts/Email.cs similarity index 97% rename from src/PhoneForge.Domain/Contacts/Email.cs rename to backend/Domain/Contacts/Email.cs index 1e4e36d..06722d4 100644 --- a/src/PhoneForge.Domain/Contacts/Email.cs +++ b/backend/Domain/Contacts/Email.cs @@ -1,7 +1,7 @@ using System.Net.Mail; -using SharedKernel; +using Domain.Core.Primitives; -namespace PhoneForge.Domain.Contacts; +namespace Domain.Contacts; /// /// Represents the email value object. diff --git a/src/PhoneForge.Domain/Contacts/FirstName.cs b/backend/Domain/Contacts/FirstName.cs similarity index 96% rename from src/PhoneForge.Domain/Contacts/FirstName.cs rename to backend/Domain/Contacts/FirstName.cs index 231f512..c813f34 100644 --- a/src/PhoneForge.Domain/Contacts/FirstName.cs +++ b/backend/Domain/Contacts/FirstName.cs @@ -1,6 +1,6 @@ -using SharedKernel; +using Domain.Core.Primitives; -namespace PhoneForge.Domain.Contacts; +namespace Domain.Contacts; /// /// Represents the first name value object. diff --git a/src/PhoneForge.Domain/Contacts/LastName.cs b/backend/Domain/Contacts/LastName.cs similarity index 96% rename from src/PhoneForge.Domain/Contacts/LastName.cs rename to backend/Domain/Contacts/LastName.cs index 0ff984b..51fb0fc 100644 --- a/src/PhoneForge.Domain/Contacts/LastName.cs +++ b/backend/Domain/Contacts/LastName.cs @@ -1,6 +1,6 @@ -using SharedKernel; +using Domain.Core.Primitives; -namespace PhoneForge.Domain.Contacts; +namespace Domain.Contacts; /// /// Represents the last name value object. diff --git a/src/PhoneForge.Domain/Contacts/PhoneNumber.cs b/backend/Domain/Contacts/PhoneNumber.cs similarity index 97% rename from src/PhoneForge.Domain/Contacts/PhoneNumber.cs rename to backend/Domain/Contacts/PhoneNumber.cs index 1c1baeb..fb0d825 100644 --- a/src/PhoneForge.Domain/Contacts/PhoneNumber.cs +++ b/backend/Domain/Contacts/PhoneNumber.cs @@ -1,6 +1,6 @@ -using SharedKernel; +using Domain.Core.Primitives; -namespace PhoneForge.Domain.Contacts; +namespace Domain.Contacts; /// /// Represents the phone number value object. diff --git a/src/SharedKernel/IAuditableEntity.cs b/backend/Domain/Core/Abstractions/IAuditableEntity.cs similarity index 91% rename from src/SharedKernel/IAuditableEntity.cs rename to backend/Domain/Core/Abstractions/IAuditableEntity.cs index 177688a..a84a1b3 100644 --- a/src/SharedKernel/IAuditableEntity.cs +++ b/backend/Domain/Core/Abstractions/IAuditableEntity.cs @@ -1,4 +1,4 @@ -namespace SharedKernel; +namespace Domain.Core.Abstractions; /// /// Represents the marker interface for auditable entities. diff --git a/src/SharedKernel/IDateTimeProvider.cs b/backend/Domain/Core/Abstractions/IDateTimeProvider.cs similarity index 87% rename from src/SharedKernel/IDateTimeProvider.cs rename to backend/Domain/Core/Abstractions/IDateTimeProvider.cs index ed44449..261609a 100644 --- a/src/SharedKernel/IDateTimeProvider.cs +++ b/backend/Domain/Core/Abstractions/IDateTimeProvider.cs @@ -1,4 +1,4 @@ -namespace SharedKernel; +namespace Domain.Core.Abstractions; /// /// Represents the interface for getting the current date and time. diff --git a/src/SharedKernel/ISoftDeletableEntity.cs b/backend/Domain/Core/Abstractions/ISoftDeletableEntity.cs similarity index 92% rename from src/SharedKernel/ISoftDeletableEntity.cs rename to backend/Domain/Core/Abstractions/ISoftDeletableEntity.cs index a71bf23..a0f1c4f 100644 --- a/src/SharedKernel/ISoftDeletableEntity.cs +++ b/backend/Domain/Core/Abstractions/ISoftDeletableEntity.cs @@ -1,4 +1,4 @@ -namespace SharedKernel; +namespace Domain.Core.Abstractions; /// /// Represents the marker interface for soft-deletable entities. diff --git a/src/SharedKernel/Entity.cs b/backend/Domain/Core/Primitives/Entity.cs similarity index 96% rename from src/SharedKernel/Entity.cs rename to backend/Domain/Core/Primitives/Entity.cs index b871625..25f710d 100644 --- a/src/SharedKernel/Entity.cs +++ b/backend/Domain/Core/Primitives/Entity.cs @@ -1,4 +1,4 @@ -namespace SharedKernel; +namespace Domain.Core.Primitives; /// /// Represents the base class that all entities derive from. diff --git a/src/SharedKernel/Error.cs b/backend/Domain/Core/Primitives/Error.cs similarity index 98% rename from src/SharedKernel/Error.cs rename to backend/Domain/Core/Primitives/Error.cs index 2887a70..c726c3d 100644 --- a/src/SharedKernel/Error.cs +++ b/backend/Domain/Core/Primitives/Error.cs @@ -1,4 +1,4 @@ -namespace SharedKernel; +namespace Domain.Core.Primitives; /// /// Represents a concrete domain error. diff --git a/src/SharedKernel/ErrorType.cs b/backend/Domain/Core/Primitives/ErrorType.cs similarity index 95% rename from src/SharedKernel/ErrorType.cs rename to backend/Domain/Core/Primitives/ErrorType.cs index 51c327d..00544b8 100644 --- a/src/SharedKernel/ErrorType.cs +++ b/backend/Domain/Core/Primitives/ErrorType.cs @@ -1,4 +1,4 @@ -namespace SharedKernel; +namespace Domain.Core.Primitives; /// /// Specifies the category of an . diff --git a/src/SharedKernel/Result.cs b/backend/Domain/Core/Primitives/Result.cs similarity index 99% rename from src/SharedKernel/Result.cs rename to backend/Domain/Core/Primitives/Result.cs index 486db1d..9f06663 100644 --- a/src/SharedKernel/Result.cs +++ b/backend/Domain/Core/Primitives/Result.cs @@ -1,4 +1,4 @@ -namespace SharedKernel; +namespace Domain.Core.Primitives; /// /// Represents a result of some operation, with status information and possibly an error. diff --git a/src/SharedKernel/ResultT.cs b/backend/Domain/Core/Primitives/ResultT.cs similarity index 98% rename from src/SharedKernel/ResultT.cs rename to backend/Domain/Core/Primitives/ResultT.cs index 44499fc..b13de19 100644 --- a/src/SharedKernel/ResultT.cs +++ b/backend/Domain/Core/Primitives/ResultT.cs @@ -1,4 +1,4 @@ -namespace SharedKernel; +namespace Domain.Core.Primitives; /// /// Represents the result of some operation, with status information and possibly a value and an error. diff --git a/src/PhoneForge.Domain/PhoneForge.Domain.csproj b/backend/Domain/Domain.csproj similarity index 71% rename from src/PhoneForge.Domain/PhoneForge.Domain.csproj rename to backend/Domain/Domain.csproj index d154527..50cc5e5 100644 --- a/src/PhoneForge.Domain/PhoneForge.Domain.csproj +++ b/backend/Domain/Domain.csproj @@ -3,6 +3,6 @@ True - + diff --git a/src/PhoneForge.Persistence/DependencyInjection.cs b/backend/Infrastructure/Core/DependencyInjection.cs similarity index 50% rename from src/PhoneForge.Persistence/DependencyInjection.cs rename to backend/Infrastructure/Core/DependencyInjection.cs index 725efee..cbda67d 100644 --- a/src/PhoneForge.Persistence/DependencyInjection.cs +++ b/backend/Infrastructure/Core/DependencyInjection.cs @@ -1,22 +1,38 @@ +using Application.Core.Abstractions.Data; +using Domain.Core.Abstractions; +using Infrastructure.Core.Time; +using Infrastructure.Database; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using PhoneForge.UseCases.Abstractions.Data; -namespace PhoneForge.Persistence; +namespace Infrastructure.Core; /// -/// Provides extension methods for registering persistence services. +/// Provides extension methods for registering infrastructure services. /// public static class DependencyInjection { /// - /// Registers the persistence layer services with the dependency injection container. + /// Registers the infrastructure layer services with the dependency injection container. /// /// The service collection. /// The application configuration. - /// The same instance, allowing for method chaining. - public static IServiceCollection AddPersistence( + /// + /// The same instance, allowing for method chaining. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration + ) + { + services.AddSingleton(); + services.AddDatabase(configuration); + + return services; + } + + private static void AddDatabase( this IServiceCollection services, IConfiguration configuration ) @@ -27,7 +43,5 @@ IConfiguration configuration ); services.AddScoped(); - - return services; } } diff --git a/src/PhoneForge.Infrastructure/Time/DateTimeProvider.cs b/backend/Infrastructure/Core/Time/DateTimeProvider.cs similarity index 75% rename from src/PhoneForge.Infrastructure/Time/DateTimeProvider.cs rename to backend/Infrastructure/Core/Time/DateTimeProvider.cs index 1db59c3..b3777dc 100644 --- a/src/PhoneForge.Infrastructure/Time/DateTimeProvider.cs +++ b/backend/Infrastructure/Core/Time/DateTimeProvider.cs @@ -1,6 +1,6 @@ -using SharedKernel; +using Domain.Core.Abstractions; -namespace PhoneForge.Infrastructure.Time; +namespace Infrastructure.Core.Time; /// /// Represents the machine date time service. diff --git a/src/PhoneForge.Persistence/Configurations/ContactConfiguration.cs b/backend/Infrastructure/Database/Configurations/ContactConfiguration.cs similarity index 96% rename from src/PhoneForge.Persistence/Configurations/ContactConfiguration.cs rename to backend/Infrastructure/Database/Configurations/ContactConfiguration.cs index 35aa4f3..a4dd739 100644 --- a/src/PhoneForge.Persistence/Configurations/ContactConfiguration.cs +++ b/backend/Infrastructure/Database/Configurations/ContactConfiguration.cs @@ -1,8 +1,8 @@ +using Domain.Contacts; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using PhoneForge.Domain.Contacts; -namespace PhoneForge.Persistence.Configurations; +namespace Infrastructure.Database.Configurations; /// /// Represents the configuration for the entity. diff --git a/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.Designer.cs b/backend/Infrastructure/Database/Migrations/20251115144452_InitialCreate.Designer.cs similarity index 98% rename from src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.Designer.cs rename to backend/Infrastructure/Database/Migrations/20251115144452_InitialCreate.Designer.cs index 0623d0b..84a0b8f 100644 --- a/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.Designer.cs +++ b/backend/Infrastructure/Database/Migrations/20251115144452_InitialCreate.Designer.cs @@ -1,15 +1,16 @@ // using System; +using Infrastructure; +using Infrastructure.Database; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using PhoneForge.Persistence; #nullable disable -namespace PhoneForge.Persistence.Migrations +namespace Infrastructure.Migrations { [DbContext(typeof(PhoneForgeDbContext))] [Migration("20251115144452_InitialCreate")] diff --git a/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs b/backend/Infrastructure/Database/Migrations/20251115144452_InitialCreate.cs similarity index 95% rename from src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs rename to backend/Infrastructure/Database/Migrations/20251115144452_InitialCreate.cs index d3bda5d..cb5b7fc 100644 --- a/src/PhoneForge.Persistence/Migrations/20251115144452_InitialCreate.cs +++ b/backend/Infrastructure/Database/Migrations/20251115144452_InitialCreate.cs @@ -1,8 +1,8 @@ -using Microsoft.EntityFrameworkCore.Migrations; +#nullable disable -#nullable disable +using Microsoft.EntityFrameworkCore.Migrations; -namespace PhoneForge.Persistence.Migrations +namespace Infrastructure.Migrations { /// public partial class InitialCreate : Migration diff --git a/src/PhoneForge.Persistence/Migrations/PhoneForgeDbContextModelSnapshot.cs b/backend/Infrastructure/Database/Migrations/PhoneForgeDbContextModelSnapshot.cs similarity index 98% rename from src/PhoneForge.Persistence/Migrations/PhoneForgeDbContextModelSnapshot.cs rename to backend/Infrastructure/Database/Migrations/PhoneForgeDbContextModelSnapshot.cs index f58477d..f903abf 100644 --- a/src/PhoneForge.Persistence/Migrations/PhoneForgeDbContextModelSnapshot.cs +++ b/backend/Infrastructure/Database/Migrations/PhoneForgeDbContextModelSnapshot.cs @@ -1,14 +1,14 @@ // using System; +using Infrastructure.Database; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using PhoneForge.Persistence; #nullable disable -namespace PhoneForge.Persistence.Migrations +namespace Infrastructure.Migrations { [DbContext(typeof(PhoneForgeDbContext))] partial class PhoneForgeDbContextModelSnapshot : ModelSnapshot diff --git a/src/PhoneForge.Persistence/PhoneForgeDbContext.cs b/backend/Infrastructure/Database/PhoneForgeDbContext.cs similarity index 96% rename from src/PhoneForge.Persistence/PhoneForgeDbContext.cs rename to backend/Infrastructure/Database/PhoneForgeDbContext.cs index d8e8811..03faf25 100644 --- a/src/PhoneForge.Persistence/PhoneForgeDbContext.cs +++ b/backend/Infrastructure/Database/PhoneForgeDbContext.cs @@ -1,10 +1,10 @@ +using Application.Core.Abstractions.Data; +using Domain.Contacts; +using Domain.Core.Abstractions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using PhoneForge.Domain.Contacts; -using PhoneForge.UseCases.Abstractions.Data; -using SharedKernel; -namespace PhoneForge.Persistence; +namespace Infrastructure.Database; /// /// Represents the applications database context. diff --git a/src/PhoneForge.Persistence/PhoneForge.Persistence.csproj b/backend/Infrastructure/Infrastructure.csproj similarity index 77% rename from src/PhoneForge.Persistence/PhoneForge.Persistence.csproj rename to backend/Infrastructure/Infrastructure.csproj index c2aaa41..e5ab340 100644 --- a/src/PhoneForge.Persistence/PhoneForge.Persistence.csproj +++ b/backend/Infrastructure/Infrastructure.csproj @@ -3,7 +3,7 @@ True - + diff --git a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs new file mode 100644 index 0000000..ddf1327 --- /dev/null +++ b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs @@ -0,0 +1,37 @@ +using Application.Contacts.Create; +using WebApi.Core; +using WebApi.Core.Extensions; +using WebApi.Core.Infrastructure; + +namespace WebApi.Contacts.Create; + +internal sealed class CreateContactEndpoint : IEndpoint +{ + public const string Name = "CreateContact"; + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost(Routes.Contacts.Create, Handler) + .WithNameAndTags(Name, Tags.Contacts) + .WithRequestValidation() + .MapToApiVersion(1); + } + + private static async Task Handler( + CreateContactRequest request, + CreateContact useCase, + CancellationToken cancellationToken + ) + { + var command = new CreateContactCommand( + request.FirstName, + request.LastName, + request.Email, + request.PhoneNumber + ); + + var result = await useCase.Handle(command, cancellationToken); + + return result.Match(Results.Ok, CustomResults.Problem); + } +} diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs b/backend/WebApi/Contacts/Create/CreateContactRequest.cs similarity index 79% rename from src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs rename to backend/WebApi/Contacts/Create/CreateContactRequest.cs index 35a7a98..7a731cd 100644 --- a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequest.cs +++ b/backend/WebApi/Contacts/Create/CreateContactRequest.cs @@ -1,7 +1,7 @@ -namespace PhoneForge.WebApi.Endpoints.V1.Contacts.Create; +namespace WebApi.Contacts.Create; /// -/// Represents the request to create a contact. +/// Represents the create contact request. /// /// The first name of the contact. /// The last name of the contact. diff --git a/backend/WebApi/Contacts/Create/CreateContactRequestValidator.cs b/backend/WebApi/Contacts/Create/CreateContactRequestValidator.cs new file mode 100644 index 0000000..ec9c1fc --- /dev/null +++ b/backend/WebApi/Contacts/Create/CreateContactRequestValidator.cs @@ -0,0 +1,32 @@ +using Domain.Contacts; +using FluentValidation; + +namespace WebApi.Contacts.Create; + +/// +/// Validates instances. +/// +public class CreateContactRequestValidator : AbstractValidator +{ + /// + /// Defines the validation rules for a . + /// + public CreateContactRequestValidator() + { + RuleFor(r => r.FirstName) + .NotEmpty() + .WithMessage(ContactErrors.FirstName.IsRequired.Description); + + RuleFor(r => r.LastName) + .NotEmpty() + .WithMessage(ContactErrors.LastName.IsRequired.Description); + + RuleFor(r => r.Email) + .NotEmpty() + .WithMessage(ContactErrors.Email.IsRequired.Description); + + RuleFor(r => r.PhoneNumber) + .NotEmpty() + .WithMessage(ContactErrors.PhoneNumber.IsRequired.Description); + } +} diff --git a/src/PhoneForge.WebApi/Extensions/ApplicationBuilderExtensions.cs b/backend/WebApi/Core/Extensions/ApplicationBuilderExtensions.cs similarity index 95% rename from src/PhoneForge.WebApi/Extensions/ApplicationBuilderExtensions.cs rename to backend/WebApi/Core/Extensions/ApplicationBuilderExtensions.cs index 0ff3f31..2f18d09 100644 --- a/src/PhoneForge.WebApi/Extensions/ApplicationBuilderExtensions.cs +++ b/backend/WebApi/Core/Extensions/ApplicationBuilderExtensions.cs @@ -1,6 +1,6 @@ using Serilog; -namespace PhoneForge.WebApi.Extensions; +namespace WebApi.Core.Extensions; /// /// Provides extension methods for configuring Serilog in the application. diff --git a/backend/WebApi/Core/Extensions/EndpointExtensions.cs b/backend/WebApi/Core/Extensions/EndpointExtensions.cs new file mode 100644 index 0000000..3b3e1a3 --- /dev/null +++ b/backend/WebApi/Core/Extensions/EndpointExtensions.cs @@ -0,0 +1,95 @@ +using System.Reflection; +using Asp.Versioning; +using Microsoft.Extensions.DependencyInjection.Extensions; +using WebApi.Core.Infrastructure; + +namespace WebApi.Core.Extensions; + +/// +/// Provides extension methods for registering and mapping API endpoints. +/// +public static class EndpointExtensions +{ + /// + /// Maps all registered implementations to the application's routing pipeline, + /// using API versioning. + /// + /// The used to map the endpoints. + /// The same instance for chaining. + public static IApplicationBuilder MapEndpoints(this WebApplication app) + { + var apiVersionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .HasApiVersion(new ApiVersion(2)) + .ReportApiVersions() + .Build(); + + var builder = app.MapGroup("api/v{apiVersion:apiVersion}") + .WithApiVersionSet(apiVersionSet); + + var endpoints = app.Services.GetRequiredService>(); + + foreach (var endpoint in endpoints) + { + endpoint.MapEndpoint(builder); + } + + return app; + } + + /// + /// Scans the specified assembly for all types implementing + /// and registers them as transient services in the dependency injection container. + /// + /// The service collection to add the endpoint services to. + /// The assembly to scan for endpoint implementations. + /// The same instance for chaining. + public static IServiceCollection AddEndpoints( + this IServiceCollection services, + Assembly assembly + ) + { + var types = assembly + .DefinedTypes.Where(type => + type is { IsAbstract: false, IsInterface: false } + && type.IsAssignableTo(typeof(IEndpoint)) + ) + .Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type)) + .ToArray(); + + services.TryAddEnumerable(types); + + return services; + } + + /// + /// Adds a request validation filter of type to the route handler + /// and configures the endpoint to produce validation problem responses. + /// + /// The type of request to validate. + /// The route handler builder to configure. + /// The same instance for chaining. + public static RouteHandlerBuilder WithRequestValidation( + this RouteHandlerBuilder app + ) + { + return app.AddEndpointFilter>() + .ProducesValidationProblem(); + } + + /// + /// Adds a name and a tag to the endpoint metadata. + /// + /// The route handler builder to configure. + /// Name of the endpoint. + /// Tag of the endpoint. + /// + public static RouteHandlerBuilder WithNameAndTags( + this RouteHandlerBuilder app, + string name, + string tags + ) + { + return app.WithName(name).WithTags(tags); + } +} diff --git a/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs b/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs similarity index 66% rename from src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs rename to backend/WebApi/Core/Extensions/MiddlewareExtensions.cs index 48240bd..d0f7d39 100644 --- a/src/PhoneForge.WebApi/Extensions/MiddlewareExtensions.cs +++ b/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs @@ -1,11 +1,9 @@ -using Asp.Versioning; +using Infrastructure.Database; using Microsoft.EntityFrameworkCore; -using PhoneForge.Persistence; -using PhoneForge.WebApi.Endpoints.V1.Contacts; -using PhoneForge.WebApi.Middleware; using Serilog; +using WebApi.Core.Middleware; -namespace PhoneForge.WebApi.Extensions; +namespace WebApi.Core.Extensions; /// /// Provides extension methods for registering application middleware. @@ -21,34 +19,21 @@ public static async Task UseWebApplicationMiddleware( this WebApplication app ) { - app.MapV1Endpoints(); + app.UseExceptionHandler(); app.UseOpenApi(); app.UseHttpsRedirection(); app.UseRequestContextLogging(); - app.UseSerilogRequestLogging(); + app.UseCustomSerilogRequestLogging(); - app.UseExceptionHandler(); + app.MapEndpoints(); await app.ApplyMigrations(); return app; } - private static void MapV1Endpoints(this WebApplication app) - { - var apiVersionSet = app.NewApiVersionSet() - .HasApiVersion(new ApiVersion(1)) - .ReportApiVersions() - .Build(); - - var versionedGroup = app.MapGroup("api/v{apiVersion:apiVersion}") - .WithApiVersionSet(apiVersionSet); - - versionedGroup.MapContactsEndpoints(); - } - private static void UseOpenApi(this WebApplication app) { if (app.Environment.IsDevelopment()) @@ -62,6 +47,21 @@ private static void UseRequestContextLogging(this WebApplication app) app.UseMiddleware(); } + private static void UseCustomSerilogRequestLogging(this WebApplication app) + { + app.UseSerilogRequestLogging(opts => + { + opts.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestMethod", httpContext.Request.Method); + diagnosticContext.Set("RequestPath", httpContext.Request.Path); + }; + + opts.MessageTemplate = + "Handled {RequestMethod} {RequestPath} in {Elapsed:0.0000} ms"; + }); + } + private static async Task ApplyMigrations(this IApplicationBuilder app) { using var scope = app.ApplicationServices.CreateScope(); diff --git a/src/PhoneForge.WebApi/Extensions/ResultExtensions.cs b/backend/WebApi/Core/Extensions/ResultExtensions.cs similarity index 96% rename from src/PhoneForge.WebApi/Extensions/ResultExtensions.cs rename to backend/WebApi/Core/Extensions/ResultExtensions.cs index e48a36f..a1c6caa 100644 --- a/src/PhoneForge.WebApi/Extensions/ResultExtensions.cs +++ b/backend/WebApi/Core/Extensions/ResultExtensions.cs @@ -1,6 +1,6 @@ -using SharedKernel; +using Domain.Core.Primitives; -namespace PhoneForge.WebApi.Extensions; +namespace WebApi.Core.Extensions; /// /// Contains extension methods for the result class. diff --git a/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs b/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs similarity index 80% rename from src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs rename to backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs index 480b282..31f2c13 100644 --- a/src/PhoneForge.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs @@ -1,11 +1,12 @@ +using System.Reflection; +using Application.Core; using Asp.Versioning; using FluentValidation; -using PhoneForge.Infrastructure; -using PhoneForge.Persistence; -using PhoneForge.UseCases; -using PhoneForge.WebApi.Infrastructure; +using Infrastructure.Core; +using Microsoft.Extensions.DependencyInjection.Extensions; +using WebApi.Core.Infrastructure; -namespace PhoneForge.WebApi.Extensions; +namespace WebApi.Core.Extensions; /// /// Provides extension methods for registering application services. @@ -25,15 +26,18 @@ IConfiguration configuration { services.AddPresentation(); services.AddUseCases(); - services.AddInfrastructure(); - services.AddPersistence(configuration); - - services.AddFluentValidation(); + services.AddInfrastructure(configuration); services.AddApiVersioning(); services.AddCustomExceptionHandler(); + services.AddCustomProblemDetails(); + + services.AddEndpoints(Assembly.GetExecutingAssembly()); + + services.AddFluentValidation(); + return services; } @@ -42,11 +46,6 @@ private static void AddPresentation(this IServiceCollection services) services.AddOpenApi(); } - private static void AddFluentValidation(this IServiceCollection services) - { - services.AddValidatorsFromAssembly(typeof(Program).Assembly); - } - private static void AddApiVersioning(this IServiceCollection services) { services @@ -66,6 +65,10 @@ private static void AddApiVersioning(this IServiceCollection services) private static void AddCustomExceptionHandler(this IServiceCollection services) { services.AddExceptionHandler(); + } + + private static void AddCustomProblemDetails(this IServiceCollection services) + { services.AddProblemDetails(configure => { configure.CustomizeProblemDetails = context => @@ -77,4 +80,9 @@ private static void AddCustomExceptionHandler(this IServiceCollection services) }; }); } + + private static void AddFluentValidation(this IServiceCollection services) + { + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + } } diff --git a/src/PhoneForge.WebApi/HttpFiles/Contacts.http b/backend/WebApi/Core/HttpFiles/Contacts.http similarity index 88% rename from src/PhoneForge.WebApi/HttpFiles/Contacts.http rename to backend/WebApi/Core/HttpFiles/Contacts.http index 34f215d..489f68c 100644 --- a/src/PhoneForge.WebApi/HttpFiles/Contacts.http +++ b/backend/WebApi/Core/HttpFiles/Contacts.http @@ -12,5 +12,5 @@ Content-type: application/json "firstName": "John", "lastName": "Doe", "email": "jdoe@gmail.com", - "phoneNumber": "091987654321" + "phoneNumber": "091212121212" } diff --git a/backend/WebApi/Core/IEndpoint.cs b/backend/WebApi/Core/IEndpoint.cs new file mode 100644 index 0000000..61459f3 --- /dev/null +++ b/backend/WebApi/Core/IEndpoint.cs @@ -0,0 +1,15 @@ +namespace WebApi.Core; + +/// +/// Represents a marker interface for types that define API endpoints. +/// Implementing this interface allows the endpoint to be automatically +/// discovered and registered in the application's routing pipeline. +/// +public interface IEndpoint +{ + /// + /// Maps the endpoint to the specified . + /// + /// The route builder used to configure the endpoint. + void MapEndpoint(IEndpointRouteBuilder app); +} diff --git a/src/PhoneForge.WebApi/Infrastructure/CustomResults.cs b/backend/WebApi/Core/Infrastructure/CustomResults.cs similarity index 83% rename from src/PhoneForge.WebApi/Infrastructure/CustomResults.cs rename to backend/WebApi/Core/Infrastructure/CustomResults.cs index ab5762d..39401b9 100644 --- a/src/PhoneForge.WebApi/Infrastructure/CustomResults.cs +++ b/backend/WebApi/Core/Infrastructure/CustomResults.cs @@ -1,6 +1,6 @@ -using SharedKernel; +using Domain.Core.Primitives; -namespace PhoneForge.WebApi.Infrastructure; +namespace WebApi.Core.Infrastructure; /// /// Provides custom result helpers for converting errors @@ -32,12 +32,11 @@ public static IResult Problem(Result result) return Results.Problem( title: GetTitle(result.Error), - detail: GetDetail(result.Error), type: GetType(result.Error.Type), statusCode: GetStatusCode(result.Error.Type), extensions: new Dictionary { - { "errors", new[] { result.Error } }, + { "errors", new[] { result.Error.Description } }, } ); @@ -52,17 +51,6 @@ static string GetTitle(Error error) }; } - static string GetDetail(Error error) - { - return error.Type switch - { - ErrorType.Validation => error.Description, - ErrorType.NotFound => error.Description, - ErrorType.Conflict => error.Description, - _ => "An unexpected error occurred", - }; - } - static string GetType(ErrorType errorType) { return errorType switch diff --git a/src/PhoneForge.WebApi/Infrastructure/GlobalExceptionHandler.cs b/backend/WebApi/Core/Infrastructure/GlobalExceptionHandler.cs similarity index 65% rename from src/PhoneForge.WebApi/Infrastructure/GlobalExceptionHandler.cs rename to backend/WebApi/Core/Infrastructure/GlobalExceptionHandler.cs index 0140326..d0ac1b3 100644 --- a/src/PhoneForge.WebApi/Infrastructure/GlobalExceptionHandler.cs +++ b/backend/WebApi/Core/Infrastructure/GlobalExceptionHandler.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; -namespace PhoneForge.WebApi.Infrastructure; +namespace WebApi.Core.Infrastructure; /// /// A global exception handler that captures unhandled exceptions and converts them @@ -11,7 +11,6 @@ public sealed class GlobalExceptionHandler : IExceptionHandler { private readonly IProblemDetailsService _problemDetailsService; private readonly ILogger _logger; - private readonly IHostEnvironment _environment; /// /// Initializes a new instance of the class. @@ -20,16 +19,13 @@ public sealed class GlobalExceptionHandler : IExceptionHandler /// The service responsible for writing responses. /// /// The logger used to record exception details. - /// Provides information about the current hosting environment. public GlobalExceptionHandler( IProblemDetailsService problemDetailsService, - ILogger logger, - IHostEnvironment environment + ILogger logger ) { _problemDetailsService = problemDetailsService; _logger = logger; - _environment = environment; } /// @@ -59,27 +55,10 @@ CancellationToken cancellationToken ProblemDetails = new ProblemDetails { Status = StatusCodes.Status500InternalServerError, - Type = exception.GetType().Name, - Title = "An error occurred", - Detail = GetErrorDetail(exception), + Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.1", + Title = "Server failure", }, } ); } - - /// - /// Returns an error message appropriate for the current hosting environment. - /// In development, the exception message is returned. In production, a generic - /// error message is used to avoid leaking internal details. - /// - /// The exception to extract details from. - /// - /// A detailed message in development, or a safe generic message in non-development environments. - /// - private string GetErrorDetail(Exception exception) - { - return _environment.IsDevelopment() - ? exception.Message - : "Something went wrong. Please try again later."; - } } diff --git a/backend/WebApi/Core/Infrastructure/ValidationFilter.cs b/backend/WebApi/Core/Infrastructure/ValidationFilter.cs new file mode 100644 index 0000000..f925c15 --- /dev/null +++ b/backend/WebApi/Core/Infrastructure/ValidationFilter.cs @@ -0,0 +1,51 @@ +using FluentValidation; + +namespace WebApi.Core.Infrastructure; + +/// +/// An endpoint filter that validates incoming requests of type +/// using FluentValidation before invoking the endpoint handler. +/// +/// The type of request to validate. +public sealed class ValidationFilter : IEndpointFilter +{ + private readonly IValidator _validator; + + /// + /// Initializes a new instance of the class. + /// + /// The validator used to validate the request. + public ValidationFilter(IValidator validator) + { + _validator = validator; + } + + /// + /// Invokes the filter, validating the request before calling the next delegate in the pipeline. + /// + /// The containing endpoint arguments. + /// The delegate representing the next filter or endpoint in the pipeline. + /// + /// Returns a validation problem result if validation fails; otherwise, continues + /// to the next filter or endpoint result. + /// + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next + ) + { + var request = context.Arguments.OfType().First(); + + var result = await _validator.ValidateAsync( + request, + context.HttpContext.RequestAborted + ); + + if (!result.IsValid) + { + return TypedResults.ValidationProblem(result.ToDictionary()); + } + + return await next(context); + } +} diff --git a/src/PhoneForge.WebApi/Middleware/RequestLogContextMiddleware.cs b/backend/WebApi/Core/Middleware/RequestLogContextMiddleware.cs similarity index 96% rename from src/PhoneForge.WebApi/Middleware/RequestLogContextMiddleware.cs rename to backend/WebApi/Core/Middleware/RequestLogContextMiddleware.cs index 9352d88..b8d50f1 100644 --- a/src/PhoneForge.WebApi/Middleware/RequestLogContextMiddleware.cs +++ b/backend/WebApi/Core/Middleware/RequestLogContextMiddleware.cs @@ -1,6 +1,6 @@ using Serilog.Context; -namespace PhoneForge.WebApi.Middleware; +namespace WebApi.Core.Middleware; /// /// Middleware that enriches the logging context with a correlation ID diff --git a/src/PhoneForge.WebApi/Endpoints/Routes.cs b/backend/WebApi/Core/Routes.cs similarity index 74% rename from src/PhoneForge.WebApi/Endpoints/Routes.cs rename to backend/WebApi/Core/Routes.cs index f3720cf..1299d3f 100644 --- a/src/PhoneForge.WebApi/Endpoints/Routes.cs +++ b/backend/WebApi/Core/Routes.cs @@ -1,4 +1,4 @@ -namespace PhoneForge.WebApi.Endpoints; +namespace WebApi.Core; /// /// Provides route definitions for the application's endpoints. @@ -10,6 +10,9 @@ public static class Routes /// public static class Contacts { + /// + /// The base route for contact related endpoints. + /// private const string Base = "contacts"; /// @@ -18,7 +21,7 @@ public static class Contacts public const string Create = Base; /// - /// The route used for retrieving a contact by identifier. + /// The route used for retrieving contact by an identifier. /// public const string GetById = $"{Base}/{{id:guid}}"; } diff --git a/backend/WebApi/Core/Tags.cs b/backend/WebApi/Core/Tags.cs new file mode 100644 index 0000000..3ecfa2b --- /dev/null +++ b/backend/WebApi/Core/Tags.cs @@ -0,0 +1,12 @@ +namespace WebApi.Core; + +/// +/// Provides string constants for endpoint tags used in API documentation and grouping. +/// +public static class Tags +{ + /// + /// The tag used for contact-related endpoints. + /// + public const string Contacts = "Contacts"; +} diff --git a/src/PhoneForge.WebApi/Program.cs b/backend/WebApi/Program.cs similarity index 86% rename from src/PhoneForge.WebApi/Program.cs rename to backend/WebApi/Program.cs index b09e6b0..576f676 100644 --- a/src/PhoneForge.WebApi/Program.cs +++ b/backend/WebApi/Program.cs @@ -1,4 +1,4 @@ -using PhoneForge.WebApi.Extensions; +using WebApi.Core.Extensions; var builder = WebApplication.CreateBuilder(args); diff --git a/src/PhoneForge.WebApi/Properties/launchSettings.json b/backend/WebApi/Properties/launchSettings.json similarity index 100% rename from src/PhoneForge.WebApi/Properties/launchSettings.json rename to backend/WebApi/Properties/launchSettings.json diff --git a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj b/backend/WebApi/WebApi.csproj similarity index 82% rename from src/PhoneForge.WebApi/PhoneForge.WebApi.csproj rename to backend/WebApi/WebApi.csproj index 9117825..9f9afc4 100644 --- a/src/PhoneForge.WebApi/PhoneForge.WebApi.csproj +++ b/backend/WebApi/WebApi.csproj @@ -5,8 +5,8 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -16,7 +16,6 @@ - - + diff --git a/src/PhoneForge.WebApi/appsettings.Development.json b/backend/WebApi/appsettings.Development.json similarity index 87% rename from src/PhoneForge.WebApi/appsettings.Development.json rename to backend/WebApi/appsettings.Development.json index faf0bf4..a766dbb 100644 --- a/src/PhoneForge.WebApi/appsettings.Development.json +++ b/backend/WebApi/appsettings.Development.json @@ -8,7 +8,8 @@ "Default": "Information", "Override": { "Microsoft": "Information", - "System": "Information" + "Microsoft.AspNetCore.Routing.EndpointMiddleware": "Warning", + "System": "Warning" } }, "WriteTo":[ diff --git a/src/PhoneForge.WebApi/appsettings.json b/backend/WebApi/appsettings.json similarity index 100% rename from src/PhoneForge.WebApi/appsettings.json rename to backend/WebApi/appsettings.json diff --git a/src/PhoneForge.Console/PhoneForge.Console.csproj b/frontend/Console/Console.csproj similarity index 100% rename from src/PhoneForge.Console/PhoneForge.Console.csproj rename to frontend/Console/Console.csproj diff --git a/src/PhoneForge.Console/Program.cs b/frontend/Console/Program.cs similarity index 100% rename from src/PhoneForge.Console/Program.cs rename to frontend/Console/Program.cs diff --git a/src/PhoneForge.Infrastructure/DependencyInjection.cs b/src/PhoneForge.Infrastructure/DependencyInjection.cs deleted file mode 100644 index ed7681c..0000000 --- a/src/PhoneForge.Infrastructure/DependencyInjection.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using PhoneForge.Infrastructure.Time; -using SharedKernel; - -namespace PhoneForge.Infrastructure; - -/// -/// Provides extension methods for registering infrastructure services. -/// -public static class DependencyInjection -{ - /// - /// Registers the infrastructure layer services with the dependency injection container. - /// - /// The service collection. - /// - /// The same instance, allowing for method chaining. - /// - public static IServiceCollection AddInfrastructure(this IServiceCollection services) - { - services.AddSingleton(); - - return services; - } -} diff --git a/src/PhoneForge.Infrastructure/PhoneForge.Infrastructure.csproj b/src/PhoneForge.Infrastructure/PhoneForge.Infrastructure.csproj deleted file mode 100644 index c5f906c..0000000 --- a/src/PhoneForge.Infrastructure/PhoneForge.Infrastructure.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - True - - - - - diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/ContactsEndpointsExtensions.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/ContactsEndpointsExtensions.cs deleted file mode 100644 index d9ef3e1..0000000 --- a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/ContactsEndpointsExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using PhoneForge.WebApi.Endpoints.V1.Contacts.Create; - -namespace PhoneForge.WebApi.Endpoints.V1.Contacts; - -/// -/// Provides extension method for mapping contact-related API endpoints. -/// -public static class ContactsEndpointsExtensions -{ - /// - /// Maps all contact-related endpoints to the application's routing pipeline. - /// - /// The route builder used to configure the endpoints. - public static void MapContactsEndpoints(this IEndpointRouteBuilder app) - { - app.MapCreateContact(); - } -} diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactEndpoint.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactEndpoint.cs deleted file mode 100644 index e5705f3..0000000 --- a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactEndpoint.cs +++ /dev/null @@ -1,44 +0,0 @@ -using PhoneForge.UseCases.Contacts.Create; -using PhoneForge.WebApi.Extensions; -using PhoneForge.WebApi.Infrastructure; - -namespace PhoneForge.WebApi.Endpoints.V1.Contacts.Create; - -/// -/// Provides endpoint mappings related to creating contacts. -/// -public static class CreateContactEndpoint -{ - /// - /// The name of the endpoint used for creating a new contact. - /// - public const string Name = "CreateContact"; - - /// - /// Maps the endpoint responsible for creating a new contact. - /// - /// The route builder used to configure the endpoint. - public static void MapCreateContact(this IEndpointRouteBuilder app) - { - app.MapPost( - Routes.Contacts.Create, - async ( - CreateContactRequest request, - CreateContact useCase, - CancellationToken cancellationToken - ) => - { - var command = new CreateContactCommand( - request.FirstName, - request.LastName, - request.Email, - request.PhoneNumber - ); - - var result = await useCase.Handle(command, cancellationToken); - - return result.Match(Results.Ok, CustomResults.Problem); - } - ); - } -} diff --git a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs b/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs deleted file mode 100644 index d8b5a34..0000000 --- a/src/PhoneForge.WebApi/Endpoints/V1/Contacts/Create/CreateContactRequestValidator.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentValidation; - -namespace PhoneForge.WebApi.Endpoints.V1.Contacts.Create; - -/// -/// Represents the class. -/// -public class CreateContactRequestValidator : AbstractValidator -{ - /// - /// Initializes a new instance of the class. - /// - public CreateContactRequestValidator() - { - RuleFor(x => x.FirstName).NotEmpty().WithMessage("The first name is required."); - RuleFor(x => x.LastName).NotEmpty().WithMessage("The last name is required."); - RuleFor(x => x.Email).NotEmpty().WithMessage("The email is required."); - RuleFor(x => x.PhoneNumber) - .NotEmpty() - .WithMessage("The phone number is required."); - } -} diff --git a/src/SharedKernel/Ensure.cs b/src/SharedKernel/Ensure.cs deleted file mode 100644 index c2257ef..0000000 --- a/src/SharedKernel/Ensure.cs +++ /dev/null @@ -1,69 +0,0 @@ -namespace SharedKernel; - -/// -/// Contains assertions for the most common application checks. -/// -public static class Ensure -{ - /// - /// Ensures that the specified value is not empty. - /// - /// The value to check. - /// The message to show if the check fails. - /// The name of the argument being checked. - /// if the specified value is empty. - public static void NotEmpty(string value, string message, string argumentName) - { - if (string.IsNullOrWhiteSpace(value)) - { - throw new ArgumentException(message, argumentName); - } - } - - /// - /// Ensures that the specified value is not empty. - /// - /// The value to check. - /// The message to show if the check fails. - /// The name of the argument being checked. - /// if the specified value is empty. - public static void NotEmpty(Guid value, string message, string argumentName) - { - if (value == Guid.Empty) - { - throw new ArgumentException(message, argumentName); - } - } - - /// - /// Ensures that the specified value is not empty. - /// - /// The value to check. - /// The message to show if the check fails. - /// The name of the argument being checked. - /// if the specified value is the default value for the type. - public static void NotEmpty(DateTime value, string message, string argumentName) - { - if (value == default) - { - throw new ArgumentException(message, argumentName); - } - } - - /// - /// Ensures that the specified value is not null. - /// - /// The value type. - /// The value to check. - /// The message to show if the check fails. - /// The name of the argument being checked. - /// if the specified value is null. - public static void NotNull(T value, string message, string argumentName) - where T : class - { - if (value is null) - { - throw new ArgumentNullException(argumentName, message); - } - } -} diff --git a/src/SharedKernel/SharedKernel.csproj b/src/SharedKernel/SharedKernel.csproj deleted file mode 100644 index 9f31b7d..0000000 --- a/src/SharedKernel/SharedKernel.csproj +++ /dev/null @@ -1,5 +0,0 @@ - - - True - - diff --git a/tests/PhoneForge.UnitTests/PhoneForge.UnitTests.csproj b/tests/UnitTests/UnitTests.csproj similarity index 100% rename from tests/PhoneForge.UnitTests/PhoneForge.UnitTests.csproj rename to tests/UnitTests/UnitTests.csproj From d3e2129b8b4ad8b7d7760905d3cca26758bbefac Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sat, 22 Nov 2025 05:21:57 +0100 Subject: [PATCH 10/46] feat: retrieve Contact by identifier (#8) * add feature to retrieve a contact by identifier * add GetContactById endpoint metadata * change Create Contact endpoint to return 201 response and add endpoint metadata --- .../Application/Contacts/ContactResponse.cs | 21 ++++++ .../Contacts/GetById/GetContactById.cs | 68 +++++++++++++++++++ .../Contacts/GetById/GetContactByIdQuery.cs | 7 ++ backend/Domain/Contacts/ContactErrors.cs | 13 ++++ .../Contacts/Create/CreateContactEndpoint.cs | 15 +++- .../GetById/GetContactByIdEndpoint.cs | 34 ++++++++++ backend/WebApi/Core/HttpFiles/Contacts.http | 5 +- backend/WebApi/Core/Routes.cs | 2 +- 8 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 backend/Application/Contacts/ContactResponse.cs create mode 100644 backend/Application/Contacts/GetById/GetContactById.cs create mode 100644 backend/Application/Contacts/GetById/GetContactByIdQuery.cs create mode 100644 backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs diff --git a/backend/Application/Contacts/ContactResponse.cs b/backend/Application/Contacts/ContactResponse.cs new file mode 100644 index 0000000..e9c33b6 --- /dev/null +++ b/backend/Application/Contacts/ContactResponse.cs @@ -0,0 +1,21 @@ +namespace Application.Contacts; + +/// +/// Represents the response returned when retrieving or creating a contact. +/// +/// The unique identifier of the contact. +/// The first name of the contact. +/// The last name of the contact. +/// The full name of the contact. +/// The email address of the contact. +/// The phone number of the contact. +/// The date and time when the contact was created in UTC format. +public record ContactResponse( + Guid Id, + string FirstName, + string LastName, + string FullName, + string Email, + string PhoneNumber, + DateTime CreatedOnUtc +); diff --git a/backend/Application/Contacts/GetById/GetContactById.cs b/backend/Application/Contacts/GetById/GetContactById.cs new file mode 100644 index 0000000..8c1c655 --- /dev/null +++ b/backend/Application/Contacts/GetById/GetContactById.cs @@ -0,0 +1,68 @@ +using Application.Core.Abstractions; +using Application.Core.Abstractions.Data; +using Domain.Contacts; +using Domain.Core.Primitives; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Application.Contacts.GetById; + +/// +/// Represents the use case. +/// +public class GetContactById : IUseCase +{ + private readonly IDbContext _context; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of class. + /// + /// The database context used to retrieve the contact. + /// The logger used to record diagnostic information. + public GetContactById(IDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + /// Handles a . + /// + /// The query containing the contact identifier. + /// A token that can be used to cancel the operation. + /// + /// A result containing a if the contact is found, + /// or an error result if the contact does not exist. + /// + public async Task> Handle( + GetContactByIdQuery query, + CancellationToken cancellationToken + ) + { + _logger.LogInformation("Processing {Query}", query); + + var contact = await _context + .Contacts.AsNoTracking() + .Where(c => c.Id == query.Id) + .Select(c => new ContactResponse( + c.Id, + c.FirstName, + c.LastName, + c.FullName, + c.Email, + c.PhoneNumber, + c.CreatedOnUtc + )) + .SingleOrDefaultAsync(cancellationToken); + + if (contact is null) + { + _logger.LogError("Error: {@Error}", ContactErrors.NotFoundById(query.Id)); + return ContactErrors.NotFoundById(query.Id); + } + + _logger.LogInformation("Completed {@Query}", query); + return contact; + } +} diff --git a/backend/Application/Contacts/GetById/GetContactByIdQuery.cs b/backend/Application/Contacts/GetById/GetContactByIdQuery.cs new file mode 100644 index 0000000..70e99c2 --- /dev/null +++ b/backend/Application/Contacts/GetById/GetContactByIdQuery.cs @@ -0,0 +1,7 @@ +namespace Application.Contacts.GetById; + +/// +/// Represents the query for getting a contact by identifier. +/// +/// The unique identifier of the contact to retrieve. +public record GetContactByIdQuery(Guid Id); diff --git a/backend/Domain/Contacts/ContactErrors.cs b/backend/Domain/Contacts/ContactErrors.cs index b3a7f6b..f03f690 100644 --- a/backend/Domain/Contacts/ContactErrors.cs +++ b/backend/Domain/Contacts/ContactErrors.cs @@ -13,6 +13,19 @@ public static class ContactErrors public static Error EmailNotUnique => Error.Conflict("Contact.EmailNotUnique", "The provided email is not unique."); + /// + /// Gets an error indicating that the contact was not found by Id. + /// + /// The unique identifier of the contact. + /// + public static Error NotFoundById(Guid contactId) + { + return Error.NotFound( + "Contact.NotFoundById", + $"The contact with the Id = {contactId} was not found." + ); + } + /// /// Contains the first name errors. /// diff --git a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs index ddf1327..4ff495b 100644 --- a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs +++ b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs @@ -1,4 +1,6 @@ +using Application.Contacts; using Application.Contacts.Create; +using WebApi.Contacts.GetById; using WebApi.Core; using WebApi.Core.Extensions; using WebApi.Core.Infrastructure; @@ -13,6 +15,9 @@ public void MapEndpoint(IEndpointRouteBuilder app) { app.MapPost(Routes.Contacts.Create, Handler) .WithNameAndTags(Name, Tags.Contacts) + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status400BadRequest) .WithRequestValidation() .MapToApiVersion(1); } @@ -32,6 +37,14 @@ CancellationToken cancellationToken var result = await useCase.Handle(command, cancellationToken); - return result.Match(Results.Ok, CustomResults.Problem); + return result.Match( + result => + Results.CreatedAtRoute( + GetContactByIdEndpoint.Name, + new { contactId = result.Id }, + result + ), + CustomResults.Problem + ); } } diff --git a/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs b/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs new file mode 100644 index 0000000..cafd27f --- /dev/null +++ b/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs @@ -0,0 +1,34 @@ +using Application.Contacts; +using Application.Contacts.GetById; +using WebApi.Core; +using WebApi.Core.Extensions; +using WebApi.Core.Infrastructure; + +namespace WebApi.Contacts.GetById; + +internal sealed class GetContactByIdEndpoint : IEndpoint +{ + public const string Name = "GetContactById"; + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Routes.Contacts.GetById, Handler) + .WithNameAndTags(Name, Tags.Contacts) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound) + .MapToApiVersion(1); + } + + private static async Task Handler( + Guid contactId, + GetContactById useCase, + CancellationToken cancellationToken + ) + { + var query = new GetContactByIdQuery(contactId); + + var result = await useCase.Handle(query, cancellationToken); + + return result.Match(Results.Ok, CustomResults.Problem); + } +} diff --git a/backend/WebApi/Core/HttpFiles/Contacts.http b/backend/WebApi/Core/HttpFiles/Contacts.http index 489f68c..322a478 100644 --- a/backend/WebApi/Core/HttpFiles/Contacts.http +++ b/backend/WebApi/Core/HttpFiles/Contacts.http @@ -1,8 +1,11 @@ @HostAddress = http://localhost:5111 -GET {{HostAddress}}/ +### Get contact by Id + +GET {{HostAddress}}/api/v1/contacts/dc421216-5672-489f-9e5d-1692c8d1d4be Accept: application/json + ### Create contact POST {{HostAddress}}/api/v1/contacts diff --git a/backend/WebApi/Core/Routes.cs b/backend/WebApi/Core/Routes.cs index 1299d3f..f2b4b5e 100644 --- a/backend/WebApi/Core/Routes.cs +++ b/backend/WebApi/Core/Routes.cs @@ -23,6 +23,6 @@ public static class Contacts /// /// The route used for retrieving contact by an identifier. /// - public const string GetById = $"{Base}/{{id:guid}}"; + public const string GetById = $"{Base}/{{contactId:guid}}"; } } From dd2f4e6922fc2242e19987f19b561c91333cfb71 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sun, 23 Nov 2025 07:10:20 +0100 Subject: [PATCH 11/46] feat: delete contact (#16) * add delete contact use case * change XML comments * change CreateContact use case to use ContactResponse * add delete contact endpoint --- .../Contacts/Create/CreateContact.cs | 15 +++-- .../Contacts/Create/CreateContactResponse.cs | 17 ----- .../Contacts/Delete/DeleteContact.cs | 63 +++++++++++++++++++ .../Contacts/Delete/DeleteContactCommand.cs | 7 +++ backend/Domain/Core/Primitives/Result.cs | 10 +++ .../Database/PhoneForgeDbContext.cs | 2 +- .../Contacts/Delete/DeleteContactEndpoint.cs | 33 ++++++++++ backend/WebApi/Core/HttpFiles/Contacts.http | 4 ++ backend/WebApi/Core/Routes.cs | 5 ++ 9 files changed, 134 insertions(+), 22 deletions(-) delete mode 100644 backend/Application/Contacts/Create/CreateContactResponse.cs create mode 100644 backend/Application/Contacts/Delete/DeleteContact.cs create mode 100644 backend/Application/Contacts/Delete/DeleteContactCommand.cs create mode 100644 backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs diff --git a/backend/Application/Contacts/Create/CreateContact.cs b/backend/Application/Contacts/Create/CreateContact.cs index b0a3733..5b05d7e 100644 --- a/backend/Application/Contacts/Create/CreateContact.cs +++ b/backend/Application/Contacts/Create/CreateContact.cs @@ -27,10 +27,15 @@ public CreateContact(IDbContext context, ILogger logger) } /// - /// Handles a . + /// Represents the handler. /// - /// Response from the command. - public async Task> Handle( + /// The command containing the new contact information. + /// A token that can be used to cancel the operation. + /// + /// A successful result containing a if the contact was created + /// or an error result. + /// + public async Task> Handle( CreateContactCommand command, CancellationToken cancellationToken ) @@ -77,8 +82,10 @@ await _context.Contacts.AnyAsync( await _context.SaveChangesAsync(cancellationToken); - var response = new CreateContactResponse( + var response = new ContactResponse( contact.Id, + contact.FirstName, + contact.LastName, contact.FullName, contact.Email, contact.PhoneNumber, diff --git a/backend/Application/Contacts/Create/CreateContactResponse.cs b/backend/Application/Contacts/Create/CreateContactResponse.cs deleted file mode 100644 index 13a722b..0000000 --- a/backend/Application/Contacts/Create/CreateContactResponse.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Application.Contacts.Create; - -/// -/// Represents the response returned after creating a new contact. -/// -/// The unique identifier of the contact. -/// The full name of the contact. -/// The email address of the contact. -/// The phone number of the contact. -/// The date and time when the contact was created in UTC format. -public sealed record CreateContactResponse( - Guid Id, - string FullName, - string Email, - string PhoneNumber, - DateTime CreatedOnUtc -); diff --git a/backend/Application/Contacts/Delete/DeleteContact.cs b/backend/Application/Contacts/Delete/DeleteContact.cs new file mode 100644 index 0000000..a9c25a4 --- /dev/null +++ b/backend/Application/Contacts/Delete/DeleteContact.cs @@ -0,0 +1,63 @@ +using Application.Core.Abstractions; +using Application.Core.Abstractions.Data; +using Domain.Contacts; +using Domain.Core.Primitives; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Application.Contacts.Delete; + +/// +/// Represents the use case. +/// +public class DeleteContact : IUseCase +{ + private readonly IDbContext _context; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The database context used to delete the contact. + /// The logger used to record diagnostic information. + public DeleteContact(IDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + /// Represents the handler. + /// + /// The command containing the contact identifier. + /// A token that can be used to cancel the operation. + /// + /// A successful result if the contact was deleted + /// or an error if the contact was not found. + /// + public async Task Handle( + DeleteContactCommand command, + CancellationToken cancellationToken + ) + { + _logger.LogInformation("Processing command {Command}", command); + + var contact = await _context.Contacts.SingleOrDefaultAsync( + c => c.Id == command.Id, + cancellationToken + ); + + if (contact is null) + { + _logger.LogError("Error: {@Error}", ContactErrors.NotFoundById(command.Id)); + return ContactErrors.NotFoundById(command.Id); + } + + _context.Contacts.Remove(contact); + + await _context.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Completed command {@Command}", command); + return Result.Success(); + } +} diff --git a/backend/Application/Contacts/Delete/DeleteContactCommand.cs b/backend/Application/Contacts/Delete/DeleteContactCommand.cs new file mode 100644 index 0000000..bb6939c --- /dev/null +++ b/backend/Application/Contacts/Delete/DeleteContactCommand.cs @@ -0,0 +1,7 @@ +namespace Application.Contacts.Delete; + +/// +/// Represents the command for deleting a contact. +/// +/// The unique identifier of the contact to delete. +public sealed record class DeleteContactCommand(Guid Id); diff --git a/backend/Domain/Core/Primitives/Result.cs b/backend/Domain/Core/Primitives/Result.cs index 9f06663..2f56a45 100644 --- a/backend/Domain/Core/Primitives/Result.cs +++ b/backend/Domain/Core/Primitives/Result.cs @@ -81,6 +81,16 @@ public static Result Failure(Error error) return new Result(default, false, error); } + /// + /// Implicitly converts an + /// into a failed . + /// + /// The error to wrap in a failure result. + public static implicit operator Result(Error error) + { + return Result.Failure(error); + } + /// /// Returns the first failure from the specified . /// If there is no failure, a success is returned. diff --git a/backend/Infrastructure/Database/PhoneForgeDbContext.cs b/backend/Infrastructure/Database/PhoneForgeDbContext.cs index 03faf25..ad723e0 100644 --- a/backend/Infrastructure/Database/PhoneForgeDbContext.cs +++ b/backend/Infrastructure/Database/PhoneForgeDbContext.cs @@ -107,7 +107,7 @@ private void UpdateSoftDeletableEntities(DateTime utcNow) /// The entity entry. private static void UpdateDeletedEntityReferencesToUnchanged(EntityEntry entity) { - if (entity.References.Any()) + if (!entity.References.Any()) { return; } diff --git a/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs b/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs new file mode 100644 index 0000000..90991f7 --- /dev/null +++ b/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs @@ -0,0 +1,33 @@ +using Application.Contacts.Delete; +using WebApi.Core; +using WebApi.Core.Extensions; +using WebApi.Core.Infrastructure; + +namespace WebApi.Contacts.Delete; + +internal sealed class DeleteContactEndpoint : IEndpoint +{ + public const string Name = "DeleteContact"; + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapDelete(Routes.Contacts.Delete, Handler) + .WithNameAndTags(Name, Tags.Contacts) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .MapToApiVersion(1); + } + + private static async Task Handler( + Guid contactId, + DeleteContact useCase, + CancellationToken cancellationToken + ) + { + var command = new DeleteContactCommand(contactId); + + var result = await useCase.Handle(command, cancellationToken); + + return result.Match(Results.NoContent, CustomResults.Problem); + } +} diff --git a/backend/WebApi/Core/HttpFiles/Contacts.http b/backend/WebApi/Core/HttpFiles/Contacts.http index 322a478..a55bbd4 100644 --- a/backend/WebApi/Core/HttpFiles/Contacts.http +++ b/backend/WebApi/Core/HttpFiles/Contacts.http @@ -17,3 +17,7 @@ Content-type: application/json "email": "jdoe@gmail.com", "phoneNumber": "091212121212" } + +### Delete contact + +DELETE {{HostAddress}}/api/v1/contacts/b0ef7a3f-e137-427b-9cd4-06c7ce6158a2 \ No newline at end of file diff --git a/backend/WebApi/Core/Routes.cs b/backend/WebApi/Core/Routes.cs index f2b4b5e..3475704 100644 --- a/backend/WebApi/Core/Routes.cs +++ b/backend/WebApi/Core/Routes.cs @@ -24,5 +24,10 @@ public static class Contacts /// The route used for retrieving contact by an identifier. /// public const string GetById = $"{Base}/{{contactId:guid}}"; + + /// + /// The route used for deleting a contact. + /// + public const string Delete = $"{Base}/{{contactId:guid}}"; } } From 9b0b3b92bad7ece6cacc84619b5b81db1c610dcf Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sun, 23 Nov 2025 07:23:19 +0100 Subject: [PATCH 12/46] change target framework to .NET 10 and update nuget packages (#23) --- Directory.Build.Props | 2 +- Directory.Packages.props | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Build.Props b/Directory.Build.Props index b2fc039..5163a68 100644 --- a/Directory.Build.Props +++ b/Directory.Build.Props @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable diff --git a/Directory.Packages.props b/Directory.Packages.props index 6a13dd9..2c2a44d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,9 +7,9 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -18,9 +18,9 @@ - - - - + + + + From 758665438b6555aeda5e81b15ba9d2149c039a69 Mon Sep 17 00:00:00 2001 From: nwdorian Date: Sun, 23 Nov 2025 07:25:55 +0100 Subject: [PATCH 13/46] change rules to prefer explicit types in .editorconfig --- .editorconfig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.editorconfig b/.editorconfig index 53d3a9e..3a1534c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -85,9 +85,9 @@ dotnet_style_prefer_collection_expression = never [*.cs] # var preferences -csharp_style_var_for_built_in_types = true:silent -csharp_style_var_when_type_is_apparent = true:silent -csharp_style_var_elsewhere = true:silent +csharp_style_var_for_built_in_types = false:error +csharp_style_var_when_type_is_apparent = false:error +csharp_style_var_elsewhere = false:error # Expression-bodied members csharp_style_expression_bodied_methods = false:silent csharp_style_expression_bodied_constructors = false:silent From c5e346a3905be3047e8b9f662bdadd33242965fc Mon Sep 17 00:00:00 2001 From: nwdorian Date: Sun, 23 Nov 2025 07:38:18 +0100 Subject: [PATCH 14/46] refactor from var to explicit types --- .editorconfig | 1 + .../Application/Contacts/Create/CreateContact.cs | 14 +++++++------- .../Application/Contacts/Delete/DeleteContact.cs | 2 +- .../Contacts/GetById/GetContactById.cs | 2 +- backend/Application/Core/DependencyInjection.cs | 4 ++-- backend/Domain/Contacts/Contact.cs | 2 +- backend/Domain/Contacts/Email.cs | 2 +- backend/Domain/Core/Primitives/Result.cs | 2 +- .../Infrastructure/Core/DependencyInjection.cs | 2 +- .../Database/PhoneForgeDbContext.cs | 16 +++++++++------- .../Contacts/Create/CreateContactEndpoint.cs | 5 +++-- .../Contacts/Delete/DeleteContactEndpoint.cs | 5 +++-- .../Contacts/GetById/GetContactByIdEndpoint.cs | 5 +++-- .../WebApi/Core/Extensions/EndpointExtensions.cs | 13 ++++++++----- .../Core/Extensions/MiddlewareExtensions.cs | 4 ++-- .../Core/Infrastructure/ValidationFilter.cs | 5 +++-- backend/WebApi/Program.cs | 4 ++-- 17 files changed, 49 insertions(+), 39 deletions(-) diff --git a/.editorconfig b/.editorconfig index 3a1534c..6ea324c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -53,6 +53,7 @@ dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_auto_properties = true:silent dotnet_style_prefer_conditional_expression_over_assignment = true:silent dotnet_style_prefer_conditional_expression_over_return = false +csharp_style_implicit_object_creation_when_type_is_apparent = true:warning ############################### # Naming Conventions # ############################### diff --git a/backend/Application/Contacts/Create/CreateContact.cs b/backend/Application/Contacts/Create/CreateContact.cs index 5b05d7e..b8f6ddf 100644 --- a/backend/Application/Contacts/Create/CreateContact.cs +++ b/backend/Application/Contacts/Create/CreateContact.cs @@ -42,12 +42,12 @@ CancellationToken cancellationToken { _logger.LogInformation("Processing command {Command}", command); - var firstNameResult = FirstName.Create(command.FirstName); - var lastNameResult = LastName.Create(command.LastName); - var emailResult = Email.Create(command.Email); - var phoneNumberResult = PhoneNumber.Create(command.PhoneNumber); + Result firstNameResult = FirstName.Create(command.FirstName); + Result lastNameResult = LastName.Create(command.LastName); + Result emailResult = Email.Create(command.Email); + Result phoneNumberResult = PhoneNumber.Create(command.PhoneNumber); - var firstFailOrSuccess = Result.FirstFailOrSuccess( + Result firstFailOrSuccess = Result.FirstFailOrSuccess( firstNameResult, lastNameResult, emailResult, @@ -71,7 +71,7 @@ await _context.Contacts.AnyAsync( return ContactErrors.EmailNotUnique; } - var contact = Contact.Create( + Contact contact = Contact.Create( firstNameResult.Value, lastNameResult.Value, emailResult.Value, @@ -82,7 +82,7 @@ await _context.Contacts.AnyAsync( await _context.SaveChangesAsync(cancellationToken); - var response = new ContactResponse( + ContactResponse response = new( contact.Id, contact.FirstName, contact.LastName, diff --git a/backend/Application/Contacts/Delete/DeleteContact.cs b/backend/Application/Contacts/Delete/DeleteContact.cs index a9c25a4..acfefae 100644 --- a/backend/Application/Contacts/Delete/DeleteContact.cs +++ b/backend/Application/Contacts/Delete/DeleteContact.cs @@ -42,7 +42,7 @@ CancellationToken cancellationToken { _logger.LogInformation("Processing command {Command}", command); - var contact = await _context.Contacts.SingleOrDefaultAsync( + Contact? contact = await _context.Contacts.SingleOrDefaultAsync( c => c.Id == command.Id, cancellationToken ); diff --git a/backend/Application/Contacts/GetById/GetContactById.cs b/backend/Application/Contacts/GetById/GetContactById.cs index 8c1c655..a291a0f 100644 --- a/backend/Application/Contacts/GetById/GetContactById.cs +++ b/backend/Application/Contacts/GetById/GetContactById.cs @@ -42,7 +42,7 @@ CancellationToken cancellationToken { _logger.LogInformation("Processing {Query}", query); - var contact = await _context + ContactResponse? contact = await _context .Contacts.AsNoTracking() .Where(c => c.Id == query.Id) .Select(c => new ContactResponse( diff --git a/backend/Application/Core/DependencyInjection.cs b/backend/Application/Core/DependencyInjection.cs index 252324b..3fd38dc 100644 --- a/backend/Application/Core/DependencyInjection.cs +++ b/backend/Application/Core/DependencyInjection.cs @@ -23,14 +23,14 @@ public static IServiceCollection AddUseCases(this IServiceCollection services) private static void AddUseCases(this IServiceCollection services, Assembly assembly) { - var types = assembly + TypeInfo[] types = assembly .DefinedTypes.Where(type => type is { IsAbstract: false, IsInterface: false } && type.IsAssignableTo(typeof(IUseCase)) ) .ToArray(); - foreach (var type in types) + foreach (TypeInfo type in types) { services.AddScoped(type, type); } diff --git a/backend/Domain/Contacts/Contact.cs b/backend/Domain/Contacts/Contact.cs index fdb37ab..c9b253d 100644 --- a/backend/Domain/Contacts/Contact.cs +++ b/backend/Domain/Contacts/Contact.cs @@ -91,7 +91,7 @@ public static Contact Create( PhoneNumber phoneNumber ) { - var contact = new Contact(firstName, lastName, email, phoneNumber); + Contact contact = new(firstName, lastName, email, phoneNumber); return contact; } } diff --git a/backend/Domain/Contacts/Email.cs b/backend/Domain/Contacts/Email.cs index 06722d4..43843ac 100644 --- a/backend/Domain/Contacts/Email.cs +++ b/backend/Domain/Contacts/Email.cs @@ -66,7 +66,7 @@ private static bool IsValidEmail(string email) { try { - var mailAdress = new MailAddress(email); + MailAddress mailAdress = new(email); return mailAdress.Address == email; } diff --git a/backend/Domain/Core/Primitives/Result.cs b/backend/Domain/Core/Primitives/Result.cs index 2f56a45..ba9256d 100644 --- a/backend/Domain/Core/Primitives/Result.cs +++ b/backend/Domain/Core/Primitives/Result.cs @@ -101,7 +101,7 @@ public static implicit operator Result(Error error) /// public static Result FirstFailOrSuccess(params Result[] results) { - foreach (var result in results) + foreach (Result result in results) { if (result.IsFailure) { diff --git a/backend/Infrastructure/Core/DependencyInjection.cs b/backend/Infrastructure/Core/DependencyInjection.cs index cbda67d..6abf47f 100644 --- a/backend/Infrastructure/Core/DependencyInjection.cs +++ b/backend/Infrastructure/Core/DependencyInjection.cs @@ -37,7 +37,7 @@ private static void AddDatabase( IConfiguration configuration ) { - var connectionString = configuration.GetConnectionString("PhoneForgeDb"); + string? connectionString = configuration.GetConnectionString("PhoneForgeDb"); services.AddDbContext(options => options.UseSqlServer(connectionString) ); diff --git a/backend/Infrastructure/Database/PhoneForgeDbContext.cs b/backend/Infrastructure/Database/PhoneForgeDbContext.cs index ad723e0..4915a78 100644 --- a/backend/Infrastructure/Database/PhoneForgeDbContext.cs +++ b/backend/Infrastructure/Database/PhoneForgeDbContext.cs @@ -43,7 +43,7 @@ public override async Task SaveChangesAsync( CancellationToken cancellationToken = default ) { - var utcNow = _dateTimeProvider.UtcNow; + DateTime utcNow = _dateTimeProvider.UtcNow; UpdateAuditableEntities(utcNow); @@ -58,9 +58,11 @@ public override async Task SaveChangesAsync( /// The current date and time in UTC format. private void UpdateAuditableEntities(DateTime utcNow) { - var entities = ChangeTracker.Entries().ToList(); + List> entities = ChangeTracker + .Entries() + .ToList(); - foreach (var entity in entities) + foreach (EntityEntry? entity in entities) { if (entity.State == EntityState.Added) { @@ -82,12 +84,12 @@ private void UpdateAuditableEntities(DateTime utcNow) /// The current date and time in UTC format. private void UpdateSoftDeletableEntities(DateTime utcNow) { - var entities = ChangeTracker + List> entities = ChangeTracker .Entries() .Where(e => e.State == EntityState.Deleted) .ToList(); - foreach (var entity in entities) + foreach (EntityEntry? entity in entities) { entity.Property(nameof(ISoftDeletableEntity.DeletedOnUtc)).CurrentValue = utcNow; @@ -112,11 +114,11 @@ private static void UpdateDeletedEntityReferencesToUnchanged(EntityEntry entity) return; } - var references = entity + IEnumerable references = entity .References.Where(r => r.TargetEntry?.State == EntityState.Deleted) .Select(r => r.TargetEntry!); - foreach (var reference in references) + foreach (EntityEntry? reference in references) { reference.State = EntityState.Unchanged; diff --git a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs index 4ff495b..736f55c 100644 --- a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs +++ b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs @@ -1,5 +1,6 @@ using Application.Contacts; using Application.Contacts.Create; +using Domain.Core.Primitives; using WebApi.Contacts.GetById; using WebApi.Core; using WebApi.Core.Extensions; @@ -28,14 +29,14 @@ private static async Task Handler( CancellationToken cancellationToken ) { - var command = new CreateContactCommand( + CreateContactCommand command = new( request.FirstName, request.LastName, request.Email, request.PhoneNumber ); - var result = await useCase.Handle(command, cancellationToken); + Result result = await useCase.Handle(command, cancellationToken); return result.Match( result => diff --git a/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs b/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs index 90991f7..ac92344 100644 --- a/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs +++ b/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs @@ -1,4 +1,5 @@ using Application.Contacts.Delete; +using Domain.Core.Primitives; using WebApi.Core; using WebApi.Core.Extensions; using WebApi.Core.Infrastructure; @@ -24,9 +25,9 @@ private static async Task Handler( CancellationToken cancellationToken ) { - var command = new DeleteContactCommand(contactId); + DeleteContactCommand command = new(contactId); - var result = await useCase.Handle(command, cancellationToken); + Result result = await useCase.Handle(command, cancellationToken); return result.Match(Results.NoContent, CustomResults.Problem); } diff --git a/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs b/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs index cafd27f..042161d 100644 --- a/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs +++ b/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs @@ -1,5 +1,6 @@ using Application.Contacts; using Application.Contacts.GetById; +using Domain.Core.Primitives; using WebApi.Core; using WebApi.Core.Extensions; using WebApi.Core.Infrastructure; @@ -25,9 +26,9 @@ private static async Task Handler( CancellationToken cancellationToken ) { - var query = new GetContactByIdQuery(contactId); + GetContactByIdQuery query = new(contactId); - var result = await useCase.Handle(query, cancellationToken); + Result result = await useCase.Handle(query, cancellationToken); return result.Match(Results.Ok, CustomResults.Problem); } diff --git a/backend/WebApi/Core/Extensions/EndpointExtensions.cs b/backend/WebApi/Core/Extensions/EndpointExtensions.cs index 3b3e1a3..6ee451b 100644 --- a/backend/WebApi/Core/Extensions/EndpointExtensions.cs +++ b/backend/WebApi/Core/Extensions/EndpointExtensions.cs @@ -1,5 +1,6 @@ using System.Reflection; using Asp.Versioning; +using Asp.Versioning.Builder; using Microsoft.Extensions.DependencyInjection.Extensions; using WebApi.Core.Infrastructure; @@ -18,18 +19,20 @@ public static class EndpointExtensions /// The same instance for chaining. public static IApplicationBuilder MapEndpoints(this WebApplication app) { - var apiVersionSet = app.NewApiVersionSet() + ApiVersionSet apiVersionSet = app.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .HasApiVersion(new ApiVersion(2)) .ReportApiVersions() .Build(); - var builder = app.MapGroup("api/v{apiVersion:apiVersion}") + RouteGroupBuilder builder = app.MapGroup("api/v{apiVersion:apiVersion}") .WithApiVersionSet(apiVersionSet); - var endpoints = app.Services.GetRequiredService>(); + IEnumerable endpoints = app.Services.GetRequiredService< + IEnumerable + >(); - foreach (var endpoint in endpoints) + foreach (IEndpoint endpoint in endpoints) { endpoint.MapEndpoint(builder); } @@ -49,7 +52,7 @@ public static IServiceCollection AddEndpoints( Assembly assembly ) { - var types = assembly + ServiceDescriptor[] types = assembly .DefinedTypes.Where(type => type is { IsAbstract: false, IsInterface: false } && type.IsAssignableTo(typeof(IEndpoint)) diff --git a/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs b/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs index d0f7d39..a1a7b4a 100644 --- a/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs +++ b/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs @@ -64,9 +64,9 @@ private static void UseCustomSerilogRequestLogging(this WebApplication app) private static async Task ApplyMigrations(this IApplicationBuilder app) { - using var scope = app.ApplicationServices.CreateScope(); + using IServiceScope scope = app.ApplicationServices.CreateScope(); - using var dbContext = + using PhoneForgeDbContext dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.MigrateAsync(); diff --git a/backend/WebApi/Core/Infrastructure/ValidationFilter.cs b/backend/WebApi/Core/Infrastructure/ValidationFilter.cs index f925c15..164acbe 100644 --- a/backend/WebApi/Core/Infrastructure/ValidationFilter.cs +++ b/backend/WebApi/Core/Infrastructure/ValidationFilter.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FluentValidation.Results; namespace WebApi.Core.Infrastructure; @@ -34,9 +35,9 @@ public ValidationFilter(IValidator validator) EndpointFilterDelegate next ) { - var request = context.Arguments.OfType().First(); + TRequest? request = context.Arguments.OfType().First(); - var result = await _validator.ValidateAsync( + ValidationResult result = await _validator.ValidateAsync( request, context.HttpContext.RequestAborted ); diff --git a/backend/WebApi/Program.cs b/backend/WebApi/Program.cs index 576f676..9bccaf9 100644 --- a/backend/WebApi/Program.cs +++ b/backend/WebApi/Program.cs @@ -1,11 +1,11 @@ using WebApi.Core.Extensions; -var builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.ConfigureSerilog(); builder.Services.AddWebApplicationServices(builder.Configuration); -var app = builder.Build(); +WebApplication app = builder.Build(); await app.UseWebApplicationMiddleware(); From d4b15606e29b4e099d084564557de78d958ef3de Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sun, 23 Nov 2025 08:03:17 +0100 Subject: [PATCH 15/46] primary constructors (#24) * change rule to prefer primary constructors in .editorconfig * refactor to primary constructors --- .editorconfig | 4 +-- .../Contacts/Create/CreateContact.cs | 33 +++++++------------ .../Contacts/Delete/DeleteContact.cs | 30 +++++------------ .../Contacts/GetById/GetContactById.cs | 26 ++++----------- backend/Domain/Core/Primitives/ResultT.cs | 22 ++++--------- .../Database/PhoneForgeDbContext.cs | 25 ++++---------- .../Infrastructure/GlobalExceptionHandler.cs | 32 ++++++------------ .../Core/Infrastructure/ValidationFilter.cs | 17 +++------- .../Middleware/RequestLogContextMiddleware.cs | 16 ++------- 9 files changed, 58 insertions(+), 147 deletions(-) diff --git a/.editorconfig b/.editorconfig index 6ea324c..b56627e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -54,6 +54,7 @@ dotnet_style_prefer_auto_properties = true:silent dotnet_style_prefer_conditional_expression_over_assignment = true:silent dotnet_style_prefer_conditional_expression_over_return = false csharp_style_implicit_object_creation_when_type_is_apparent = true:warning +csharp_style_prefer_primary_constructors = true:warning ############################### # Naming Conventions # ############################### @@ -148,9 +149,6 @@ csharp_preserve_single_line_blocks = true # Maximum line length max_line_length = 90 -# IDE0290 Use primary constructor -csharp_style_prefer_primary_constructors = false - # CA1848: Use the LoggerMessage delegates dotnet_diagnostic.CA1848.severity = none diff --git a/backend/Application/Contacts/Create/CreateContact.cs b/backend/Application/Contacts/Create/CreateContact.cs index b8f6ddf..aaa1b8b 100644 --- a/backend/Application/Contacts/Create/CreateContact.cs +++ b/backend/Application/Contacts/Create/CreateContact.cs @@ -10,22 +10,11 @@ namespace Application.Contacts.Create; /// /// Represents the use case. /// -public sealed class CreateContact : IUseCase +/// The database context used to persist the new contact. +/// The logger used to record diagnostic information. +public sealed class CreateContact(IDbContext context, ILogger logger) + : IUseCase { - private readonly IDbContext _context; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The database context used to persist the new contact. - /// The logger used to record diagnostic information. - public CreateContact(IDbContext context, ILogger logger) - { - _context = context; - _logger = logger; - } - /// /// Represents the handler. /// @@ -40,7 +29,7 @@ public async Task> Handle( CancellationToken cancellationToken ) { - _logger.LogInformation("Processing command {Command}", command); + logger.LogInformation("Processing command {Command}", command); Result firstNameResult = FirstName.Create(command.FirstName); Result lastNameResult = LastName.Create(command.LastName); @@ -56,18 +45,18 @@ CancellationToken cancellationToken if (firstFailOrSuccess.IsFailure) { - _logger.LogWarning("Error: {@Error}", firstFailOrSuccess.Error); + logger.LogWarning("Error: {@Error}", firstFailOrSuccess.Error); return firstFailOrSuccess.Error; } if ( - await _context.Contacts.AnyAsync( + await context.Contacts.AnyAsync( c => c.Email.Value == command.Email, cancellationToken ) ) { - _logger.LogWarning("Error: {@Error}", ContactErrors.EmailNotUnique); + logger.LogWarning("Error: {@Error}", ContactErrors.EmailNotUnique); return ContactErrors.EmailNotUnique; } @@ -78,9 +67,9 @@ await _context.Contacts.AnyAsync( phoneNumberResult.Value ); - _context.Contacts.Add(contact); + context.Contacts.Add(contact); - await _context.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); ContactResponse response = new( contact.Id, @@ -92,7 +81,7 @@ await _context.Contacts.AnyAsync( contact.CreatedOnUtc ); - _logger.LogInformation("Completed command {@Command}", command); + logger.LogInformation("Completed command {@Command}", command); return response; } } diff --git a/backend/Application/Contacts/Delete/DeleteContact.cs b/backend/Application/Contacts/Delete/DeleteContact.cs index acfefae..d044d68 100644 --- a/backend/Application/Contacts/Delete/DeleteContact.cs +++ b/backend/Application/Contacts/Delete/DeleteContact.cs @@ -10,22 +10,10 @@ namespace Application.Contacts.Delete; /// /// Represents the use case. /// -public class DeleteContact : IUseCase +/// The database context used to delete the contact. +/// The logger used to record diagnostic information. +public class DeleteContact(IDbContext context, ILogger logger) : IUseCase { - private readonly IDbContext _context; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The database context used to delete the contact. - /// The logger used to record diagnostic information. - public DeleteContact(IDbContext context, ILogger logger) - { - _context = context; - _logger = logger; - } - /// /// Represents the handler. /// @@ -40,24 +28,24 @@ public async Task Handle( CancellationToken cancellationToken ) { - _logger.LogInformation("Processing command {Command}", command); + logger.LogInformation("Processing command {Command}", command); - Contact? contact = await _context.Contacts.SingleOrDefaultAsync( + Contact? contact = await context.Contacts.SingleOrDefaultAsync( c => c.Id == command.Id, cancellationToken ); if (contact is null) { - _logger.LogError("Error: {@Error}", ContactErrors.NotFoundById(command.Id)); + logger.LogError("Error: {@Error}", ContactErrors.NotFoundById(command.Id)); return ContactErrors.NotFoundById(command.Id); } - _context.Contacts.Remove(contact); + context.Contacts.Remove(contact); - await _context.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); - _logger.LogInformation("Completed command {@Command}", command); + logger.LogInformation("Completed command {@Command}", command); return Result.Success(); } } diff --git a/backend/Application/Contacts/GetById/GetContactById.cs b/backend/Application/Contacts/GetById/GetContactById.cs index a291a0f..0b137ad 100644 --- a/backend/Application/Contacts/GetById/GetContactById.cs +++ b/backend/Application/Contacts/GetById/GetContactById.cs @@ -10,22 +10,10 @@ namespace Application.Contacts.GetById; /// /// Represents the use case. /// -public class GetContactById : IUseCase +/// The database context used to retrieve the contact. +/// The logger used to record diagnostic information. +public class GetContactById(IDbContext context, ILogger logger) : IUseCase { - private readonly IDbContext _context; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of class. - /// - /// The database context used to retrieve the contact. - /// The logger used to record diagnostic information. - public GetContactById(IDbContext context, ILogger logger) - { - _context = context; - _logger = logger; - } - /// /// Handles a . /// @@ -40,9 +28,9 @@ public async Task> Handle( CancellationToken cancellationToken ) { - _logger.LogInformation("Processing {Query}", query); + logger.LogInformation("Processing {Query}", query); - ContactResponse? contact = await _context + ContactResponse? contact = await context .Contacts.AsNoTracking() .Where(c => c.Id == query.Id) .Select(c => new ContactResponse( @@ -58,11 +46,11 @@ CancellationToken cancellationToken if (contact is null) { - _logger.LogError("Error: {@Error}", ContactErrors.NotFoundById(query.Id)); + logger.LogError("Error: {@Error}", ContactErrors.NotFoundById(query.Id)); return ContactErrors.NotFoundById(query.Id); } - _logger.LogInformation("Completed {@Query}", query); + logger.LogInformation("Completed {@Query}", query); return contact; } } diff --git a/backend/Domain/Core/Primitives/ResultT.cs b/backend/Domain/Core/Primitives/ResultT.cs index b13de19..9cfa112 100644 --- a/backend/Domain/Core/Primitives/ResultT.cs +++ b/backend/Domain/Core/Primitives/ResultT.cs @@ -4,22 +4,12 @@ namespace Domain.Core.Primitives; /// Represents the result of some operation, with status information and possibly a value and an error. /// /// The result value type. -public class Result : Result +/// The result value. +/// The flag indicating if the result is successful. +/// The error. +public class Result(TValue? value, bool isSuccess, Error error) + : Result(isSuccess, error) { - private readonly TValue? _value; - - /// - /// Initializes a new instance of the class with the specified parameters. - /// - /// The result value. - /// The flag indicating if the result is successful. - /// The error. - public Result(TValue? value, bool isSuccess, Error error) - : base(isSuccess, error) - { - _value = value; - } - /// /// Gets the result value if the result is successful, otherwise throws an exception. /// @@ -27,7 +17,7 @@ public Result(TValue? value, bool isSuccess, Error error) /// when is true. public TValue Value => IsSuccess - ? _value! + ? value! : throw new InvalidOperationException( "The value of a failure result can't be accessed." ); diff --git a/backend/Infrastructure/Database/PhoneForgeDbContext.cs b/backend/Infrastructure/Database/PhoneForgeDbContext.cs index 4915a78..ad8623c 100644 --- a/backend/Infrastructure/Database/PhoneForgeDbContext.cs +++ b/backend/Infrastructure/Database/PhoneForgeDbContext.cs @@ -9,24 +9,13 @@ namespace Infrastructure.Database; /// /// Represents the applications database context. /// -public sealed class PhoneForgeDbContext : DbContext, IDbContext +/// The database context options. +/// The current date and time in UTC format. +public sealed class PhoneForgeDbContext( + DbContextOptions options, + IDateTimeProvider dateTimeProvider +) : DbContext(options), IDbContext { - private readonly IDateTimeProvider _dateTimeProvider; - - /// - /// Initializes a new instance of the class. - /// - /// The database context options. - /// The current date and time in UTC format. - public PhoneForgeDbContext( - DbContextOptions options, - IDateTimeProvider dateTimeProvider - ) - : base(options) - { - _dateTimeProvider = dateTimeProvider; - } - /// public DbSet Contacts { get; set; } @@ -43,7 +32,7 @@ public override async Task SaveChangesAsync( CancellationToken cancellationToken = default ) { - DateTime utcNow = _dateTimeProvider.UtcNow; + DateTime utcNow = dateTimeProvider.UtcNow; UpdateAuditableEntities(utcNow); diff --git a/backend/WebApi/Core/Infrastructure/GlobalExceptionHandler.cs b/backend/WebApi/Core/Infrastructure/GlobalExceptionHandler.cs index d0ac1b3..b989e2d 100644 --- a/backend/WebApi/Core/Infrastructure/GlobalExceptionHandler.cs +++ b/backend/WebApi/Core/Infrastructure/GlobalExceptionHandler.cs @@ -7,27 +7,15 @@ namespace WebApi.Core.Infrastructure; /// A global exception handler that captures unhandled exceptions and converts them /// into standardized responses. /// -public sealed class GlobalExceptionHandler : IExceptionHandler +/// +/// The service responsible for writing responses. +/// +/// The logger used to record exception details. +public sealed class GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger +) : IExceptionHandler { - private readonly IProblemDetailsService _problemDetailsService; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// - /// The service responsible for writing responses. - /// - /// The logger used to record exception details. - public GlobalExceptionHandler( - IProblemDetailsService problemDetailsService, - ILogger logger - ) - { - _problemDetailsService = problemDetailsService; - _logger = logger; - } - /// /// Attempts to handle an unhandled exception by logging it and returning a standardized /// response to the client. @@ -45,9 +33,9 @@ public async ValueTask TryHandleAsync( CancellationToken cancellationToken ) { - _logger.LogError(exception, "Unhandled exception occurred"); + logger.LogError(exception, "Unhandled exception occurred"); - return await _problemDetailsService.TryWriteAsync( + return await problemDetailsService.TryWriteAsync( new ProblemDetailsContext { HttpContext = httpContext, diff --git a/backend/WebApi/Core/Infrastructure/ValidationFilter.cs b/backend/WebApi/Core/Infrastructure/ValidationFilter.cs index 164acbe..964e8dd 100644 --- a/backend/WebApi/Core/Infrastructure/ValidationFilter.cs +++ b/backend/WebApi/Core/Infrastructure/ValidationFilter.cs @@ -8,19 +8,10 @@ namespace WebApi.Core.Infrastructure; /// using FluentValidation before invoking the endpoint handler. /// /// The type of request to validate. -public sealed class ValidationFilter : IEndpointFilter +/// The validator used to validate the request. +public sealed class ValidationFilter(IValidator validator) + : IEndpointFilter { - private readonly IValidator _validator; - - /// - /// Initializes a new instance of the class. - /// - /// The validator used to validate the request. - public ValidationFilter(IValidator validator) - { - _validator = validator; - } - /// /// Invokes the filter, validating the request before calling the next delegate in the pipeline. /// @@ -37,7 +28,7 @@ EndpointFilterDelegate next { TRequest? request = context.Arguments.OfType().First(); - ValidationResult result = await _validator.ValidateAsync( + ValidationResult result = await validator.ValidateAsync( request, context.HttpContext.RequestAborted ); diff --git a/backend/WebApi/Core/Middleware/RequestLogContextMiddleware.cs b/backend/WebApi/Core/Middleware/RequestLogContextMiddleware.cs index b8d50f1..9aa2a75 100644 --- a/backend/WebApi/Core/Middleware/RequestLogContextMiddleware.cs +++ b/backend/WebApi/Core/Middleware/RequestLogContextMiddleware.cs @@ -6,19 +6,9 @@ namespace WebApi.Core.Middleware; /// Middleware that enriches the logging context with a correlation ID /// for each incoming HTTP request. /// -public class RequestLogContextMiddleware +/// The next middleware in the request pipeline. +public class RequestLogContextMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; - - /// - /// Initializes a new instance of the class. - /// - /// The next middleware in the request pipeline. - public RequestLogContextMiddleware(RequestDelegate next) - { - _next = next; - } - /// /// Adds the current request's correlation ID to the Serilog log context /// and invokes the next middleware in the pipeline. @@ -29,7 +19,7 @@ public Task InvokeAsync(HttpContext context) { using (LogContext.PushProperty("CorrelationId", context.TraceIdentifier)) { - return _next(context); + return next(context); } } } From 079822ce641696dd65dbf78f2354401182098727 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sun, 23 Nov 2025 12:21:33 +0100 Subject: [PATCH 16/46] command and query abstractions (#26) * add command and query abstractions * add logging decorator * implement scrutor registration and decorators * refactor use cases to use command and query handlers * add error to log context in the logging decorator * move Serilog configuration from appsettings.json to code --- .editorconfig | 13 +- Directory.Packages.props | 52 ++++---- backend/Application/Application.csproj | 2 + .../Contacts/Create/CreateContact.cs | 26 +--- .../Contacts/Create/CreateContactCommand.cs | 6 +- .../Contacts/Delete/DeleteContact.cs | 24 +--- .../Contacts/Delete/DeleteContactCommand.cs | 4 +- .../Contacts/GetById/GetContactById.cs | 24 +--- .../Contacts/GetById/GetContactByIdQuery.cs | 4 +- .../Behaviors/LoggingDecorator.cs | 120 ++++++++++++++++++ .../Application/Core/Abstractions/IUseCase.cs | 7 - .../Core/Abstractions/Messaging/ICommand.cs | 12 ++ .../Abstractions/Messaging/ICommandHandler.cs | 36 ++++++ .../Core/Abstractions/Messaging/IQuery.cs | 7 + .../Abstractions/Messaging/IQueryHandler.cs | 20 +++ .../Application/Core/DependencyInjection.cs | 57 ++++++--- .../Contacts/Create/CreateContactEndpoint.cs | 3 +- .../Contacts/Delete/DeleteContactEndpoint.cs | 3 +- .../GetById/GetContactByIdEndpoint.cs | 3 +- .../ApplicationBuilderExtensions.cs | 14 ++ .../Extensions/ServiceCollectionExtensions.cs | 2 +- backend/WebApi/appsettings.Development.json | 19 +-- backend/WebApi/appsettings.json | 19 +-- 23 files changed, 320 insertions(+), 157 deletions(-) create mode 100644 backend/Application/Core/Abstractions/Behaviors/LoggingDecorator.cs delete mode 100644 backend/Application/Core/Abstractions/IUseCase.cs create mode 100644 backend/Application/Core/Abstractions/Messaging/ICommand.cs create mode 100644 backend/Application/Core/Abstractions/Messaging/ICommandHandler.cs create mode 100644 backend/Application/Core/Abstractions/Messaging/IQuery.cs create mode 100644 backend/Application/Core/Abstractions/Messaging/IQueryHandler.cs diff --git a/.editorconfig b/.editorconfig index b56627e..e7226a0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -155,14 +155,17 @@ dotnet_diagnostic.CA1848.severity = none # S1135: Track uses of "TODO" tags dotnet_diagnostic.S1135.severity = none -[**/Result.cs] -dotnet_diagnostic.CA1000.severity = none +# S2326: Unused type parameters should be removed +dotnet_diagnostic.S2326.severity = none -# CA1716 Identifiers should not match keywords [**/Error.cs] +# CA1716 Identifiers should not match keywords dotnet_diagnostic.CA1716.severity = none -# EF CORE MIGRATIONS +[**/Result.cs] +# CA1000: Do not declare static members on generic types +dotnet_diagnostic.CA1000.severity = none + [**/Migrations/*.cs] # IDE0161 Use file-scoped namespace (EF Core migrations are auto-generated with block-scoped namespace) -dotnet_diagnostic.IDE0161.severity = none +dotnet_diagnostic.IDE0161.severity = none \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 2c2a44d..81470a2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,26 +1,28 @@ - - true - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - + + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/Application/Application.csproj b/backend/Application/Application.csproj index 6770921..b039920 100644 --- a/backend/Application/Application.csproj +++ b/backend/Application/Application.csproj @@ -7,5 +7,7 @@ + + diff --git a/backend/Application/Contacts/Create/CreateContact.cs b/backend/Application/Contacts/Create/CreateContact.cs index aaa1b8b..de989cb 100644 --- a/backend/Application/Contacts/Create/CreateContact.cs +++ b/backend/Application/Contacts/Create/CreateContact.cs @@ -1,36 +1,19 @@ -using Application.Core.Abstractions; using Application.Core.Abstractions.Data; +using Application.Core.Abstractions.Messaging; using Domain.Contacts; using Domain.Core.Primitives; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace Application.Contacts.Create; -/// -/// Represents the use case. -/// -/// The database context used to persist the new contact. -/// The logger used to record diagnostic information. -public sealed class CreateContact(IDbContext context, ILogger logger) - : IUseCase +internal sealed class CreateContact(IDbContext context) + : ICommandHandler { - /// - /// Represents the handler. - /// - /// The command containing the new contact information. - /// A token that can be used to cancel the operation. - /// - /// A successful result containing a if the contact was created - /// or an error result. - /// public async Task> Handle( CreateContactCommand command, CancellationToken cancellationToken ) { - logger.LogInformation("Processing command {Command}", command); - Result firstNameResult = FirstName.Create(command.FirstName); Result lastNameResult = LastName.Create(command.LastName); Result emailResult = Email.Create(command.Email); @@ -45,7 +28,6 @@ CancellationToken cancellationToken if (firstFailOrSuccess.IsFailure) { - logger.LogWarning("Error: {@Error}", firstFailOrSuccess.Error); return firstFailOrSuccess.Error; } @@ -56,7 +38,6 @@ await context.Contacts.AnyAsync( ) ) { - logger.LogWarning("Error: {@Error}", ContactErrors.EmailNotUnique); return ContactErrors.EmailNotUnique; } @@ -81,7 +62,6 @@ await context.Contacts.AnyAsync( contact.CreatedOnUtc ); - logger.LogInformation("Completed command {@Command}", command); return response; } } diff --git a/backend/Application/Contacts/Create/CreateContactCommand.cs b/backend/Application/Contacts/Create/CreateContactCommand.cs index 3b5a863..f7f95ec 100644 --- a/backend/Application/Contacts/Create/CreateContactCommand.cs +++ b/backend/Application/Contacts/Create/CreateContactCommand.cs @@ -1,3 +1,5 @@ +using Application.Core.Abstractions.Messaging; + namespace Application.Contacts.Create; /// @@ -7,9 +9,9 @@ namespace Application.Contacts.Create; /// The last name of the contact. /// The email of the contact. /// The phone number of the contact. -public sealed record class CreateContactCommand( +public sealed record CreateContactCommand( string FirstName, string LastName, string Email, string PhoneNumber -); +) : ICommand; diff --git a/backend/Application/Contacts/Delete/DeleteContact.cs b/backend/Application/Contacts/Delete/DeleteContact.cs index d044d68..a0b43ec 100644 --- a/backend/Application/Contacts/Delete/DeleteContact.cs +++ b/backend/Application/Contacts/Delete/DeleteContact.cs @@ -1,35 +1,19 @@ -using Application.Core.Abstractions; using Application.Core.Abstractions.Data; +using Application.Core.Abstractions.Messaging; using Domain.Contacts; using Domain.Core.Primitives; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace Application.Contacts.Delete; -/// -/// Represents the use case. -/// -/// The database context used to delete the contact. -/// The logger used to record diagnostic information. -public class DeleteContact(IDbContext context, ILogger logger) : IUseCase +internal sealed class DeleteContact(IDbContext context) + : ICommandHandler { - /// - /// Represents the handler. - /// - /// The command containing the contact identifier. - /// A token that can be used to cancel the operation. - /// - /// A successful result if the contact was deleted - /// or an error if the contact was not found. - /// public async Task Handle( DeleteContactCommand command, CancellationToken cancellationToken ) { - logger.LogInformation("Processing command {Command}", command); - Contact? contact = await context.Contacts.SingleOrDefaultAsync( c => c.Id == command.Id, cancellationToken @@ -37,7 +21,6 @@ CancellationToken cancellationToken if (contact is null) { - logger.LogError("Error: {@Error}", ContactErrors.NotFoundById(command.Id)); return ContactErrors.NotFoundById(command.Id); } @@ -45,7 +28,6 @@ CancellationToken cancellationToken await context.SaveChangesAsync(cancellationToken); - logger.LogInformation("Completed command {@Command}", command); return Result.Success(); } } diff --git a/backend/Application/Contacts/Delete/DeleteContactCommand.cs b/backend/Application/Contacts/Delete/DeleteContactCommand.cs index bb6939c..7a65fcf 100644 --- a/backend/Application/Contacts/Delete/DeleteContactCommand.cs +++ b/backend/Application/Contacts/Delete/DeleteContactCommand.cs @@ -1,7 +1,9 @@ +using Application.Core.Abstractions.Messaging; + namespace Application.Contacts.Delete; /// /// Represents the command for deleting a contact. /// /// The unique identifier of the contact to delete. -public sealed record class DeleteContactCommand(Guid Id); +public sealed record DeleteContactCommand(Guid Id) : ICommand; diff --git a/backend/Application/Contacts/GetById/GetContactById.cs b/backend/Application/Contacts/GetById/GetContactById.cs index 0b137ad..b6aa844 100644 --- a/backend/Application/Contacts/GetById/GetContactById.cs +++ b/backend/Application/Contacts/GetById/GetContactById.cs @@ -1,35 +1,19 @@ -using Application.Core.Abstractions; using Application.Core.Abstractions.Data; +using Application.Core.Abstractions.Messaging; using Domain.Contacts; using Domain.Core.Primitives; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace Application.Contacts.GetById; -/// -/// Represents the use case. -/// -/// The database context used to retrieve the contact. -/// The logger used to record diagnostic information. -public class GetContactById(IDbContext context, ILogger logger) : IUseCase +internal sealed class GetContactById(IDbContext context) + : IQueryHandler { - /// - /// Handles a . - /// - /// The query containing the contact identifier. - /// A token that can be used to cancel the operation. - /// - /// A result containing a if the contact is found, - /// or an error result if the contact does not exist. - /// public async Task> Handle( GetContactByIdQuery query, CancellationToken cancellationToken ) { - logger.LogInformation("Processing {Query}", query); - ContactResponse? contact = await context .Contacts.AsNoTracking() .Where(c => c.Id == query.Id) @@ -46,11 +30,9 @@ CancellationToken cancellationToken if (contact is null) { - logger.LogError("Error: {@Error}", ContactErrors.NotFoundById(query.Id)); return ContactErrors.NotFoundById(query.Id); } - logger.LogInformation("Completed {@Query}", query); return contact; } } diff --git a/backend/Application/Contacts/GetById/GetContactByIdQuery.cs b/backend/Application/Contacts/GetById/GetContactByIdQuery.cs index 70e99c2..c51ee1c 100644 --- a/backend/Application/Contacts/GetById/GetContactByIdQuery.cs +++ b/backend/Application/Contacts/GetById/GetContactByIdQuery.cs @@ -1,7 +1,9 @@ +using Application.Core.Abstractions.Messaging; + namespace Application.Contacts.GetById; /// /// Represents the query for getting a contact by identifier. /// /// The unique identifier of the contact to retrieve. -public record GetContactByIdQuery(Guid Id); +public sealed record GetContactByIdQuery(Guid Id) : IQuery; diff --git a/backend/Application/Core/Abstractions/Behaviors/LoggingDecorator.cs b/backend/Application/Core/Abstractions/Behaviors/LoggingDecorator.cs new file mode 100644 index 0000000..93d90e3 --- /dev/null +++ b/backend/Application/Core/Abstractions/Behaviors/LoggingDecorator.cs @@ -0,0 +1,120 @@ +using Application.Core.Abstractions.Messaging; +using Domain.Core.Primitives; +using Microsoft.Extensions.Logging; +using Serilog.Context; + +namespace Application.Core.Abstractions.Behaviors; + +internal static class LoggingDecorator +{ + internal sealed class CommandHandler( + ICommandHandler innerHandler, + ILogger> logger + ) : ICommandHandler + where TCommand : ICommand + { + public async Task> Handle( + TCommand command, + CancellationToken cancellationToken + ) + { + string commandName = typeof(TCommand).Name; + + logger.LogInformation("Processing command {Command}", commandName); + + Result result = await innerHandler.Handle( + command, + cancellationToken + ); + + if (result.IsSuccess) + { + logger.LogInformation("Completed command {Command}", commandName); + } + else + { + using (LogContext.PushProperty("Error", result.Error, true)) + { + logger.LogError( + "Completed command {Command} with error", + commandName + ); + } + } + + return result; + } + } + + internal sealed class CommandBaseHandler( + ICommandHandler innerHandler, + ILogger> logger + ) : ICommandHandler + where TCommand : ICommand + { + public async Task Handle( + TCommand command, + CancellationToken cancellationToken + ) + { + string commandName = typeof(TCommand).Name; + + logger.LogInformation("Processing command {Command}", commandName); + + Result result = await innerHandler.Handle(command, cancellationToken); + + if (result.IsSuccess) + { + logger.LogInformation("Completed command {Command}", commandName); + } + else + { + using (LogContext.PushProperty("Error", result.Error, true)) + { + logger.LogError( + "Completed command {Command} with error", + commandName + ); + } + } + + return result; + } + } + + internal sealed class QueryHandler( + IQueryHandler innerHandler, + ILogger> logger + ) : IQueryHandler + where TQuery : IQuery + { + public async Task> Handle( + TQuery query, + CancellationToken cancellationToken + ) + { + string queryName = typeof(TQuery).Name; + + logger.LogInformation("Processing query {Query}", queryName); + + Result result = await innerHandler.Handle( + query, + cancellationToken + ); + + if (result.IsSuccess) + { + logger.LogInformation("Completed query {Query}", queryName); + } + else + { + using (LogContext.PushProperty("Error", result.Error, true)) + { + logger.LogError("Completed query {Query} with error", queryName); + } + } + + return result; + } + } +} diff --git a/backend/Application/Core/Abstractions/IUseCase.cs b/backend/Application/Core/Abstractions/IUseCase.cs deleted file mode 100644 index c650d12..0000000 --- a/backend/Application/Core/Abstractions/IUseCase.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Application.Core.Abstractions; - -/// -/// Represents a use case marker interface. -/// Used with DI to register use case implementations. -/// -public interface IUseCase; diff --git a/backend/Application/Core/Abstractions/Messaging/ICommand.cs b/backend/Application/Core/Abstractions/Messaging/ICommand.cs new file mode 100644 index 0000000..0eae66d --- /dev/null +++ b/backend/Application/Core/Abstractions/Messaging/ICommand.cs @@ -0,0 +1,12 @@ +namespace Application.Core.Abstractions.Messaging; + +/// +/// Represents a command with no return value. +/// +public interface ICommand; + +/// +/// Represents a command that produces a response of the specified type. +/// +/// The type of the response returned by the command. +public interface ICommand { } diff --git a/backend/Application/Core/Abstractions/Messaging/ICommandHandler.cs b/backend/Application/Core/Abstractions/Messaging/ICommandHandler.cs new file mode 100644 index 0000000..d6ff29a --- /dev/null +++ b/backend/Application/Core/Abstractions/Messaging/ICommandHandler.cs @@ -0,0 +1,36 @@ +using Domain.Core.Primitives; + +namespace Application.Core.Abstractions.Messaging; + +/// +/// Defines a handler for commands that do not return a value. +/// +/// The type of command to handle. +public interface ICommandHandler + where TCommand : ICommand +{ + /// + /// Handles the specified command. + /// + /// The command to handle. + /// A token used to cancel the operation. + /// Task containing the result. + Task Handle(TCommand command, CancellationToken cancellationToken); +} + +/// +/// Defines a handler for commands that return a value. +/// +/// The type of command to handle. +/// The type of the response returned by the command. +public interface ICommandHandler + where TCommand : ICommand +{ + /// + /// Handles the specified command. + /// + /// The command to handle. + /// A token used to cancel the operation. + /// Task containing the result. + Task> Handle(TCommand command, CancellationToken cancellationToken); +} diff --git a/backend/Application/Core/Abstractions/Messaging/IQuery.cs b/backend/Application/Core/Abstractions/Messaging/IQuery.cs new file mode 100644 index 0000000..68608d6 --- /dev/null +++ b/backend/Application/Core/Abstractions/Messaging/IQuery.cs @@ -0,0 +1,7 @@ +namespace Application.Core.Abstractions.Messaging; + +/// +/// Represents a query that returns a response of the specified type. +/// +/// The type of the response returned by the query. +public interface IQuery; diff --git a/backend/Application/Core/Abstractions/Messaging/IQueryHandler.cs b/backend/Application/Core/Abstractions/Messaging/IQueryHandler.cs new file mode 100644 index 0000000..3ef56ed --- /dev/null +++ b/backend/Application/Core/Abstractions/Messaging/IQueryHandler.cs @@ -0,0 +1,20 @@ +using Domain.Core.Primitives; + +namespace Application.Core.Abstractions.Messaging; + +/// +/// Defines a handler for queries that return a response of the specified type. +/// +/// The type of query to handle. +/// The type of response returned by the query. +public interface IQueryHandler + where TQuery : IQuery +{ + /// + /// Handles the specified query. + /// + /// The query to handle. + /// A token used to cancel the operation. + /// Task containing the result with the response. + Task> Handle(TQuery query, CancellationToken cancellationToken); +} diff --git a/backend/Application/Core/DependencyInjection.cs b/backend/Application/Core/DependencyInjection.cs index 3fd38dc..c9d7052 100644 --- a/backend/Application/Core/DependencyInjection.cs +++ b/backend/Application/Core/DependencyInjection.cs @@ -1,5 +1,5 @@ -using System.Reflection; -using Application.Core.Abstractions; +using Application.Core.Abstractions.Behaviors; +using Application.Core.Abstractions.Messaging; using Microsoft.Extensions.DependencyInjection; namespace Application.Core; @@ -14,25 +14,52 @@ public static class DependencyInjection /// /// The service collection. /// The same instance, allowing for method chaining. - public static IServiceCollection AddUseCases(this IServiceCollection services) + public static IServiceCollection AddApplication(this IServiceCollection services) { - services.AddUseCases(typeof(IUseCase).Assembly); + services.AddUseCases(); + services.AddDecorators(); return services; } - private static void AddUseCases(this IServiceCollection services, Assembly assembly) + private static void AddUseCases(this IServiceCollection services) { - TypeInfo[] types = assembly - .DefinedTypes.Where(type => - type is { IsAbstract: false, IsInterface: false } - && type.IsAssignableTo(typeof(IUseCase)) - ) - .ToArray(); + services.Scan(scan => + scan.FromAssembliesOf(typeof(DependencyInjection)) + .AddClasses( + classes => classes.AssignableTo(typeof(IQueryHandler<,>)), + publicOnly: false + ) + .AsImplementedInterfaces() + .WithScopedLifetime() + .AddClasses( + classes => classes.AssignableTo(typeof(ICommandHandler<>)), + publicOnly: false + ) + .AsImplementedInterfaces() + .WithScopedLifetime() + .AddClasses( + classes => classes.AssignableTo(typeof(ICommandHandler<,>)), + publicOnly: false + ) + .AsImplementedInterfaces() + .WithScopedLifetime() + ); + } - foreach (TypeInfo type in types) - { - services.AddScoped(type, type); - } + private static void AddDecorators(this IServiceCollection services) + { + services.Decorate( + typeof(IQueryHandler<,>), + typeof(LoggingDecorator.QueryHandler<,>) + ); + services.Decorate( + typeof(ICommandHandler<,>), + typeof(LoggingDecorator.CommandHandler<,>) + ); + services.Decorate( + typeof(ICommandHandler<>), + typeof(LoggingDecorator.CommandBaseHandler<>) + ); } } diff --git a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs index 736f55c..d501f90 100644 --- a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs +++ b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs @@ -1,5 +1,6 @@ using Application.Contacts; using Application.Contacts.Create; +using Application.Core.Abstractions.Messaging; using Domain.Core.Primitives; using WebApi.Contacts.GetById; using WebApi.Core; @@ -25,7 +26,7 @@ public void MapEndpoint(IEndpointRouteBuilder app) private static async Task Handler( CreateContactRequest request, - CreateContact useCase, + ICommandHandler useCase, CancellationToken cancellationToken ) { diff --git a/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs b/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs index ac92344..7868e16 100644 --- a/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs +++ b/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs @@ -1,4 +1,5 @@ using Application.Contacts.Delete; +using Application.Core.Abstractions.Messaging; using Domain.Core.Primitives; using WebApi.Core; using WebApi.Core.Extensions; @@ -21,7 +22,7 @@ public void MapEndpoint(IEndpointRouteBuilder app) private static async Task Handler( Guid contactId, - DeleteContact useCase, + ICommandHandler useCase, CancellationToken cancellationToken ) { diff --git a/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs b/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs index 042161d..91554c7 100644 --- a/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs +++ b/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs @@ -1,5 +1,6 @@ using Application.Contacts; using Application.Contacts.GetById; +using Application.Core.Abstractions.Messaging; using Domain.Core.Primitives; using WebApi.Core; using WebApi.Core.Extensions; @@ -22,7 +23,7 @@ public void MapEndpoint(IEndpointRouteBuilder app) private static async Task Handler( Guid contactId, - GetContactById useCase, + IQueryHandler useCase, CancellationToken cancellationToken ) { diff --git a/backend/WebApi/Core/Extensions/ApplicationBuilderExtensions.cs b/backend/WebApi/Core/Extensions/ApplicationBuilderExtensions.cs index 2f18d09..145b184 100644 --- a/backend/WebApi/Core/Extensions/ApplicationBuilderExtensions.cs +++ b/backend/WebApi/Core/Extensions/ApplicationBuilderExtensions.cs @@ -1,4 +1,6 @@ +using System.Globalization; using Serilog; +using Serilog.Formatting.Json; namespace WebApi.Core.Extensions; @@ -23,6 +25,18 @@ this WebApplicationBuilder builder (context, config) => { config.ReadFrom.Configuration(context.Configuration); + + config + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) + .WriteTo.File( + new JsonFormatter(renderMessage: true), + "../../logs/log-.txt", + rollingInterval: RollingInterval.Day, + rollOnFileSizeLimit: true, + shared: true + ); + + config.Enrich.FromLogContext(); } ); diff --git a/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs b/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs index 31f2c13..93869a8 100644 --- a/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs +++ b/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs @@ -25,7 +25,7 @@ IConfiguration configuration ) { services.AddPresentation(); - services.AddUseCases(); + services.AddApplication(); services.AddInfrastructure(configuration); services.AddApiVersioning(); diff --git a/backend/WebApi/appsettings.Development.json b/backend/WebApi/appsettings.Development.json index a766dbb..3789457 100644 --- a/backend/WebApi/appsettings.Development.json +++ b/backend/WebApi/appsettings.Development.json @@ -3,7 +3,6 @@ "PhoneForgeDb": "Data Source=(localdb)\\MSSQLLocalDB; Initial Catalog=PhoneForgeDb; Integrated Security=SSPI" }, "Serilog":{ - "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"], "MinimumLevel": { "Default": "Information", "Override": { @@ -11,19 +10,7 @@ "Microsoft.AspNetCore.Routing.EndpointMiddleware": "Warning", "System": "Warning" } - }, - "WriteTo":[ - {"Name": "Console"}, - { - "Name": "File", - "Args": { - "path": "../../logs/log-.txt", - "rollingInterval": "Day", - "rollOnFileSizeLimit": true, - "formatter": "Serilog.Formatting.Json.JsonFormatter" - } - } - ], - "Enrich": [ "FromLogContext" ] - } + } + }, + "AllowedHosts": "*" } diff --git a/backend/WebApi/appsettings.json b/backend/WebApi/appsettings.json index 6799e37..2ec9b6b 100644 --- a/backend/WebApi/appsettings.json +++ b/backend/WebApi/appsettings.json @@ -3,27 +3,14 @@ "PhoneForgeDb": "" }, "Serilog":{ - "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"], "MinimumLevel": { "Default": "Information", "Override": { - "Microsoft": "Warning", + "Microsoft": "Information", + "Microsoft.AspNetCore.Routing.EndpointMiddleware": "Warning", "System": "Warning" } - }, - "WriteTo":[ - {"Name": "Console"}, - { - "Name": "File", - "Args": { - "path": "../../logs/log-.txt", - "rollingInterval": "Day", - "rollOnFileSizeLimit": true, - "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" - } - } - ], - "Enrich": [ "FromLogContext" ] + } }, "AllowedHosts": "*" } From 18a269812f60a6d6a8ebc56dcdadc2a098c9e03d Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:59:27 +0100 Subject: [PATCH 17/46] use EF complex types (#27) * refactor from owned entities to complex properties and generate migrations * remove the recursive method from soft delete logic --- Directory.Packages.props | 54 +++++----- .../Configurations/ContactConfiguration.cs | 64 +++++------ ...22_RefactorToComplexProperties.Designer.cs | 96 +++++++++++++++++ ...51124203322_RefactorToComplexProperties.cs | 58 ++++++++++ .../20251124204711_FixColumnNames.Designer.cs | 100 ++++++++++++++++++ .../20251124204711_FixColumnNames.cs | 58 ++++++++++ .../PhoneForgeDbContextModelSnapshot.cs | 72 +++---------- .../Database/PhoneForgeDbContext.cs | 26 ----- 8 files changed, 376 insertions(+), 152 deletions(-) create mode 100644 backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.Designer.cs create mode 100644 backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.cs create mode 100644 backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.Designer.cs create mode 100644 backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 81470a2..747fef0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,28 +1,28 @@ - - true - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - \ No newline at end of file + + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/backend/Infrastructure/Database/Configurations/ContactConfiguration.cs b/backend/Infrastructure/Database/Configurations/ContactConfiguration.cs index a4dd739..a278282 100644 --- a/backend/Infrastructure/Database/Configurations/ContactConfiguration.cs +++ b/backend/Infrastructure/Database/Configurations/ContactConfiguration.cs @@ -17,60 +17,44 @@ public void Configure(EntityTypeBuilder builder) { builder.HasKey(contact => contact.Id); - builder.OwnsOne( - contact => contact.FirstName, - firstNameBuilder => - { - firstNameBuilder.WithOwner(); - - firstNameBuilder - .Property(firstName => firstName.Value) + builder.ComplexProperty( + c => c.FirstName, + firstName => + firstName + .Property(f => f.Value) .HasColumnName(nameof(Contact.FirstName)) .HasMaxLength(FirstName.MaxLength) - .IsRequired(); - } + .IsRequired() ); - builder.OwnsOne( - contact => contact.LastName, - lastNameBuilder => - { - lastNameBuilder.WithOwner(); - - lastNameBuilder - .Property(lastName => lastName.Value) + builder.ComplexProperty( + c => c.LastName, + lastName => + lastName + .Property(l => l.Value) .HasColumnName(nameof(Contact.LastName)) .HasMaxLength(LastName.MaxLength) - .IsRequired(); - } + .IsRequired() ); - builder.OwnsOne( - contact => contact.Email, - emailBuilder => - { - emailBuilder.WithOwner(); - - emailBuilder - .Property(email => email.Value) + builder.ComplexProperty( + c => c.Email, + email => + email + .Property(e => e.Value) .HasColumnName(nameof(Contact.Email)) .HasMaxLength(Email.MaxLength) - .IsRequired(); - } + .IsRequired() ); - builder.OwnsOne( - contact => contact.PhoneNumber, - phoneNumberBuilder => - { - phoneNumberBuilder.WithOwner(); - - phoneNumberBuilder - .Property(phoneNumber => phoneNumber.Value) + builder.ComplexProperty( + c => c.PhoneNumber, + phoneNumber => + phoneNumber + .Property(p => p.Value) .HasColumnName(nameof(Contact.PhoneNumber)) .HasMaxLength(PhoneNumber.MaxLength) - .IsRequired(); - } + .IsRequired() ); builder.Property(contact => contact.DeletedOnUtc); diff --git a/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.Designer.cs b/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.Designer.cs new file mode 100644 index 0000000..ea1175d --- /dev/null +++ b/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.Designer.cs @@ -0,0 +1,96 @@ +// +using System; +using System.Collections.Generic; +using Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(PhoneForgeDbContext))] + [Migration("20251124203322_RefactorToComplexProperties")] + partial class RefactorToComplexProperties + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Contacts.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOnUtc") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("DeletedOnUtc") + .HasColumnType("datetime2"); + + b.Property("ModifiedOnUtc") + .HasColumnType("datetime2"); + + b.ComplexProperty(typeof(Dictionary), "Email", "Domain.Contacts.Contact.Email#Email", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + }); + + b.ComplexProperty(typeof(Dictionary), "FirstName", "Domain.Contacts.Contact.FirstName#FirstName", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + }); + + b.ComplexProperty(typeof(Dictionary), "LastName", "Domain.Contacts.Contact.LastName#LastName", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + }); + + b.ComplexProperty(typeof(Dictionary), "PhoneNumber", "Domain.Contacts.Contact.PhoneNumber#PhoneNumber", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + }); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.cs b/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.cs new file mode 100644 index 0000000..5e3f06a --- /dev/null +++ b/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class RefactorToComplexProperties : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "PhoneNumber", + table: "Contacts", + newName: "PhoneNumber_Value"); + + migrationBuilder.RenameColumn( + name: "LastName", + table: "Contacts", + newName: "LastName_Value"); + + migrationBuilder.RenameColumn( + name: "FirstName", + table: "Contacts", + newName: "FirstName_Value"); + + migrationBuilder.RenameColumn( + name: "Email", + table: "Contacts", + newName: "Email_Value"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "PhoneNumber_Value", + table: "Contacts", + newName: "PhoneNumber"); + + migrationBuilder.RenameColumn( + name: "LastName_Value", + table: "Contacts", + newName: "LastName"); + + migrationBuilder.RenameColumn( + name: "FirstName_Value", + table: "Contacts", + newName: "FirstName"); + + migrationBuilder.RenameColumn( + name: "Email_Value", + table: "Contacts", + newName: "Email"); + } + } +} diff --git a/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.Designer.cs b/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.Designer.cs new file mode 100644 index 0000000..b02729f --- /dev/null +++ b/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.Designer.cs @@ -0,0 +1,100 @@ +// +using System; +using System.Collections.Generic; +using Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(PhoneForgeDbContext))] + [Migration("20251124204711_FixColumnNames")] + partial class FixColumnNames + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Contacts.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOnUtc") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("DeletedOnUtc") + .HasColumnType("datetime2"); + + b.Property("ModifiedOnUtc") + .HasColumnType("datetime2"); + + b.ComplexProperty(typeof(Dictionary), "Email", "Domain.Contacts.Contact.Email#Email", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("Email"); + }); + + b.ComplexProperty(typeof(Dictionary), "FirstName", "Domain.Contacts.Contact.FirstName#FirstName", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("FirstName"); + }); + + b.ComplexProperty(typeof(Dictionary), "LastName", "Domain.Contacts.Contact.LastName#LastName", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("LastName"); + }); + + b.ComplexProperty(typeof(Dictionary), "PhoneNumber", "Domain.Contacts.Contact.PhoneNumber#PhoneNumber", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("PhoneNumber"); + }); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.cs b/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.cs new file mode 100644 index 0000000..04bb314 --- /dev/null +++ b/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class FixColumnNames : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "PhoneNumber_Value", + table: "Contacts", + newName: "PhoneNumber"); + + migrationBuilder.RenameColumn( + name: "LastName_Value", + table: "Contacts", + newName: "LastName"); + + migrationBuilder.RenameColumn( + name: "FirstName_Value", + table: "Contacts", + newName: "FirstName"); + + migrationBuilder.RenameColumn( + name: "Email_Value", + table: "Contacts", + newName: "Email"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "PhoneNumber", + table: "Contacts", + newName: "PhoneNumber_Value"); + + migrationBuilder.RenameColumn( + name: "LastName", + table: "Contacts", + newName: "LastName_Value"); + + migrationBuilder.RenameColumn( + name: "FirstName", + table: "Contacts", + newName: "FirstName_Value"); + + migrationBuilder.RenameColumn( + name: "Email", + table: "Contacts", + newName: "Email_Value"); + } + } +} diff --git a/backend/Infrastructure/Database/Migrations/PhoneForgeDbContextModelSnapshot.cs b/backend/Infrastructure/Database/Migrations/PhoneForgeDbContextModelSnapshot.cs index f903abf..6558402 100644 --- a/backend/Infrastructure/Database/Migrations/PhoneForgeDbContextModelSnapshot.cs +++ b/backend/Infrastructure/Database/Migrations/PhoneForgeDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Infrastructure.Database; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -17,12 +18,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("PhoneForge.Domain.Contacts.Contact", b => + modelBuilder.Entity("Domain.Contacts.Contact", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -42,100 +43,53 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ModifiedOnUtc") .HasColumnType("datetime2"); - b.HasKey("Id"); - - b.ToTable("Contacts"); - }); - - modelBuilder.Entity("PhoneForge.Domain.Contacts.Contact", b => - { - b.OwnsOne("PhoneForge.Domain.Contacts.Email", "Email", b1 => + b.ComplexProperty(typeof(Dictionary), "Email", "Domain.Contacts.Contact.Email#Email", b1 => { - b1.Property("ContactId") - .HasColumnType("uniqueidentifier"); + b1.IsRequired(); b1.Property("Value") .IsRequired() .HasMaxLength(100) .HasColumnType("nvarchar(100)") .HasColumnName("Email"); - - b1.HasKey("ContactId"); - - b1.ToTable("Contacts"); - - b1.WithOwner() - .HasForeignKey("ContactId"); }); - b.OwnsOne("PhoneForge.Domain.Contacts.FirstName", "FirstName", b1 => + b.ComplexProperty(typeof(Dictionary), "FirstName", "Domain.Contacts.Contact.FirstName#FirstName", b1 => { - b1.Property("ContactId") - .HasColumnType("uniqueidentifier"); + b1.IsRequired(); b1.Property("Value") .IsRequired() .HasMaxLength(100) .HasColumnType("nvarchar(100)") .HasColumnName("FirstName"); - - b1.HasKey("ContactId"); - - b1.ToTable("Contacts"); - - b1.WithOwner() - .HasForeignKey("ContactId"); }); - b.OwnsOne("PhoneForge.Domain.Contacts.LastName", "LastName", b1 => + b.ComplexProperty(typeof(Dictionary), "LastName", "Domain.Contacts.Contact.LastName#LastName", b1 => { - b1.Property("ContactId") - .HasColumnType("uniqueidentifier"); + b1.IsRequired(); b1.Property("Value") .IsRequired() .HasMaxLength(100) .HasColumnType("nvarchar(100)") .HasColumnName("LastName"); - - b1.HasKey("ContactId"); - - b1.ToTable("Contacts"); - - b1.WithOwner() - .HasForeignKey("ContactId"); }); - b.OwnsOne("PhoneForge.Domain.Contacts.PhoneNumber", "PhoneNumber", b1 => + b.ComplexProperty(typeof(Dictionary), "PhoneNumber", "Domain.Contacts.Contact.PhoneNumber#PhoneNumber", b1 => { - b1.Property("ContactId") - .HasColumnType("uniqueidentifier"); + b1.IsRequired(); b1.Property("Value") .IsRequired() .HasMaxLength(20) .HasColumnType("nvarchar(20)") .HasColumnName("PhoneNumber"); - - b1.HasKey("ContactId"); - - b1.ToTable("Contacts"); - - b1.WithOwner() - .HasForeignKey("ContactId"); }); - b.Navigation("Email") - .IsRequired(); - - b.Navigation("FirstName") - .IsRequired(); - - b.Navigation("LastName") - .IsRequired(); + b.HasKey("Id"); - b.Navigation("PhoneNumber") - .IsRequired(); + b.ToTable("Contacts"); }); #pragma warning restore 612, 618 } diff --git a/backend/Infrastructure/Database/PhoneForgeDbContext.cs b/backend/Infrastructure/Database/PhoneForgeDbContext.cs index ad8623c..fd0c529 100644 --- a/backend/Infrastructure/Database/PhoneForgeDbContext.cs +++ b/backend/Infrastructure/Database/PhoneForgeDbContext.cs @@ -86,32 +86,6 @@ private void UpdateSoftDeletableEntities(DateTime utcNow) entity.Property(nameof(ISoftDeletableEntity.Deleted)).CurrentValue = true; entity.State = EntityState.Modified; - - UpdateDeletedEntityReferencesToUnchanged(entity); - } - } - - /// - /// Updates the specified entity entry's referenced entries in the deleted state to the unchanged state. - /// This method is recursive. - /// - /// The entity entry. - private static void UpdateDeletedEntityReferencesToUnchanged(EntityEntry entity) - { - if (!entity.References.Any()) - { - return; - } - - IEnumerable references = entity - .References.Where(r => r.TargetEntry?.State == EntityState.Deleted) - .Select(r => r.TargetEntry!); - - foreach (EntityEntry? reference in references) - { - reference.State = EntityState.Unchanged; - - UpdateDeletedEntityReferencesToUnchanged(reference); } } } From c2e9c7d83d9887c6736a174364f335d228a895af Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Wed, 26 Nov 2025 02:11:58 +0100 Subject: [PATCH 18/46] update contact (#28) * refactor unique email check to use Email value object instead of command property * add UpdateContact use case * add UpdateContact endpoint --- .../Contacts/Create/CreateContact.cs | 2 +- .../Contacts/Update/UpdateContact.cs | 65 +++++++++++++++++++ .../Contacts/Update/UpdateContactCommand.cs | 19 ++++++ backend/Domain/Contacts/Contact.cs | 20 ++++++ .../Contacts/Update/UpdateContactEndpoint.cs | 45 +++++++++++++ .../Contacts/Update/UpdateContactRequest.cs | 15 +++++ .../Update/UpdateContactRequestValidator.cs | 32 +++++++++ backend/WebApi/Core/HttpFiles/Contacts.http | 17 ++++- backend/WebApi/Core/Routes.cs | 5 ++ 9 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 backend/Application/Contacts/Update/UpdateContact.cs create mode 100644 backend/Application/Contacts/Update/UpdateContactCommand.cs create mode 100644 backend/WebApi/Contacts/Update/UpdateContactEndpoint.cs create mode 100644 backend/WebApi/Contacts/Update/UpdateContactRequest.cs create mode 100644 backend/WebApi/Contacts/Update/UpdateContactRequestValidator.cs diff --git a/backend/Application/Contacts/Create/CreateContact.cs b/backend/Application/Contacts/Create/CreateContact.cs index de989cb..d368e08 100644 --- a/backend/Application/Contacts/Create/CreateContact.cs +++ b/backend/Application/Contacts/Create/CreateContact.cs @@ -33,7 +33,7 @@ CancellationToken cancellationToken if ( await context.Contacts.AnyAsync( - c => c.Email.Value == command.Email, + c => c.Email.Value == emailResult.Value, cancellationToken ) ) diff --git a/backend/Application/Contacts/Update/UpdateContact.cs b/backend/Application/Contacts/Update/UpdateContact.cs new file mode 100644 index 0000000..8baac83 --- /dev/null +++ b/backend/Application/Contacts/Update/UpdateContact.cs @@ -0,0 +1,65 @@ +using Application.Core.Abstractions.Data; +using Application.Core.Abstractions.Messaging; +using Domain.Contacts; +using Domain.Core.Primitives; +using Microsoft.EntityFrameworkCore; + +namespace Application.Contacts.Update; + +internal sealed class UpdateContact(IDbContext context) + : ICommandHandler +{ + public async Task Handle( + UpdateContactCommand command, + CancellationToken cancellationToken + ) + { + Contact? contact = await context.Contacts.SingleOrDefaultAsync( + c => c.Id == command.Id, + cancellationToken + ); + + if (contact is null) + { + return ContactErrors.NotFoundById(command.Id); + } + + Result firstNameResult = FirstName.Create(command.FirstName); + Result lastNameResult = LastName.Create(command.LastName); + Result emailResult = Email.Create(command.Email); + Result phoneNumberResult = PhoneNumber.Create(command.PhoneNumber); + + Result firstFailOrSuccess = Result.FirstFailOrSuccess( + firstNameResult, + lastNameResult, + emailResult, + phoneNumberResult + ); + + if (firstFailOrSuccess.IsFailure) + { + return firstFailOrSuccess.Error; + } + + if ( + await context.Contacts.AnyAsync( + c => c.Email == emailResult.Value, + cancellationToken + ) + ) + { + return ContactErrors.EmailNotUnique; + } + + contact.UpdateContact( + firstNameResult.Value, + lastNameResult.Value, + emailResult.Value, + phoneNumberResult.Value + ); + + await context.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/backend/Application/Contacts/Update/UpdateContactCommand.cs b/backend/Application/Contacts/Update/UpdateContactCommand.cs new file mode 100644 index 0000000..a64f070 --- /dev/null +++ b/backend/Application/Contacts/Update/UpdateContactCommand.cs @@ -0,0 +1,19 @@ +using Application.Core.Abstractions.Messaging; + +namespace Application.Contacts.Update; + +/// +/// Represents the command for deleting a contact. +/// +/// The identifier of the contact. +/// The first name of the contact. +/// The last name of the contact. +/// The email of the contact. +/// The phone number of the contact. +public record class UpdateContactCommand( + Guid Id, + string FirstName, + string LastName, + string Email, + string PhoneNumber +) : ICommand; diff --git a/backend/Domain/Contacts/Contact.cs b/backend/Domain/Contacts/Contact.cs index c9b253d..cd8f658 100644 --- a/backend/Domain/Contacts/Contact.cs +++ b/backend/Domain/Contacts/Contact.cs @@ -94,4 +94,24 @@ PhoneNumber phoneNumber Contact contact = new(firstName, lastName, email, phoneNumber); return contact; } + + /// + /// Updates the first and last name of the contact. + /// + /// The first name. + /// The last name. + /// The email. + /// The phone number. + public void UpdateContact( + FirstName firstName, + LastName lastName, + Email email, + PhoneNumber phoneNumber + ) + { + FirstName = firstName; + LastName = lastName; + Email = email; + PhoneNumber = phoneNumber; + } } diff --git a/backend/WebApi/Contacts/Update/UpdateContactEndpoint.cs b/backend/WebApi/Contacts/Update/UpdateContactEndpoint.cs new file mode 100644 index 0000000..62e4b40 --- /dev/null +++ b/backend/WebApi/Contacts/Update/UpdateContactEndpoint.cs @@ -0,0 +1,45 @@ +using Application.Contacts.Update; +using Application.Core.Abstractions.Messaging; +using Domain.Core.Primitives; +using WebApi.Core; +using WebApi.Core.Extensions; +using WebApi.Core.Infrastructure; + +namespace WebApi.Contacts.Update; + +internal sealed class UpdateContactEndpoint : IEndpoint +{ + public const string Name = "UpdateContact"; + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPut(Routes.Contacts.Update, Handler) + .WithNameAndTags(Name, Tags.Contacts) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithRequestValidation() + .MapToApiVersion(1); + } + + private static async Task Handler( + Guid contactId, + UpdateContactRequest request, + ICommandHandler useCase, + CancellationToken cancellationToken + ) + { + UpdateContactCommand command = new( + contactId, + request.FirstName, + request.LastName, + request.Email, + request.PhoneNumber + ); + + Result result = await useCase.Handle(command, cancellationToken); + + return result.Match(Results.NoContent, CustomResults.Problem); + } +} diff --git a/backend/WebApi/Contacts/Update/UpdateContactRequest.cs b/backend/WebApi/Contacts/Update/UpdateContactRequest.cs new file mode 100644 index 0000000..a25ceb3 --- /dev/null +++ b/backend/WebApi/Contacts/Update/UpdateContactRequest.cs @@ -0,0 +1,15 @@ +namespace WebApi.Contacts.Update; + +/// +/// The request to update a contact. +/// +/// The first name. +/// The last name. +/// The email. +/// The phone number. +public sealed record UpdateContactRequest( + string FirstName, + string LastName, + string Email, + string PhoneNumber +); diff --git a/backend/WebApi/Contacts/Update/UpdateContactRequestValidator.cs b/backend/WebApi/Contacts/Update/UpdateContactRequestValidator.cs new file mode 100644 index 0000000..3a5fb9d --- /dev/null +++ b/backend/WebApi/Contacts/Update/UpdateContactRequestValidator.cs @@ -0,0 +1,32 @@ +using Domain.Contacts; +using FluentValidation; + +namespace WebApi.Contacts.Update; + +/// +/// Validates the instances. +/// +public class UpdateContactRequestValidator : AbstractValidator +{ + /// + /// Defines the validation rules for + /// + public UpdateContactRequestValidator() + { + RuleFor(r => r.FirstName) + .NotEmpty() + .WithMessage(ContactErrors.FirstName.IsRequired.Description); + + RuleFor(r => r.LastName) + .NotEmpty() + .WithMessage(ContactErrors.LastName.IsRequired.Description); + + RuleFor(r => r.Email) + .NotEmpty() + .WithMessage(ContactErrors.Email.IsRequired.Description); + + RuleFor(r => r.PhoneNumber) + .NotEmpty() + .WithMessage(ContactErrors.PhoneNumber.IsRequired.Description); + } +} diff --git a/backend/WebApi/Core/HttpFiles/Contacts.http b/backend/WebApi/Core/HttpFiles/Contacts.http index a55bbd4..bc6ecca 100644 --- a/backend/WebApi/Core/HttpFiles/Contacts.http +++ b/backend/WebApi/Core/HttpFiles/Contacts.http @@ -2,7 +2,7 @@ ### Get contact by Id -GET {{HostAddress}}/api/v1/contacts/dc421216-5672-489f-9e5d-1692c8d1d4be +GET {{HostAddress}}/api/v1/contacts/81023bac-163f-4eb0-bfb5-a834ef213d16 Accept: application/json @@ -20,4 +20,17 @@ Content-type: application/json ### Delete contact -DELETE {{HostAddress}}/api/v1/contacts/b0ef7a3f-e137-427b-9cd4-06c7ce6158a2 \ No newline at end of file +DELETE {{HostAddress}}/api/v1/contacts/b0ef7a3f-e137-427b-9cd4-06c7ce6158a2 +Accept: application/json + +### Update contact + +PUT {{HostAddress}}/api/v1/contacts/81023bac-163f-4eb0-bfb5-a834ef213d16 +Content-Type: application/json + +{ + "firstName": "John", + "lastName": "Doe", + "email": "jdoe2@gmail.com", + "phoneNumber": "091212121212" +} \ No newline at end of file diff --git a/backend/WebApi/Core/Routes.cs b/backend/WebApi/Core/Routes.cs index 3475704..fe5157f 100644 --- a/backend/WebApi/Core/Routes.cs +++ b/backend/WebApi/Core/Routes.cs @@ -29,5 +29,10 @@ public static class Contacts /// The route used for deleting a contact. /// public const string Delete = $"{Base}/{{contactId:guid}}"; + + /// + /// The route used for updating a contact. + /// + public const string Update = $"{Base}/{{contactId:guid}}"; } } From de81b3d6b26e3eb9799def5e430a466928e24326 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:22:27 +0100 Subject: [PATCH 19/46] get paginated contacts (#31) * add PagedList * add GetContacts feature --- .editorconfig | 1 - .../Application/Contacts/Get/GetContacts.cs | 89 +++++++++++++++++++ .../Contacts/Get/GetContactsQuery.cs | 20 +++++ backend/Domain/Core/Pagination/Page.cs | 43 +++++++++ backend/Domain/Core/Pagination/PageSize.cs | 58 ++++++++++++ backend/Domain/Core/Pagination/PagedList.cs | 47 ++++++++++ .../Core/Pagination/PaginationErrors.cs | 33 +++++++ backend/Domain/Domain.csproj | 3 - .../Contacts/Create/CreateContactEndpoint.cs | 1 + .../Contacts/Delete/DeleteContactEndpoint.cs | 1 + .../Contacts/Get/GetContactsEndpoint.cs | 41 +++++++++ .../WebApi/Contacts/Get/GetContactsRequest.cs | 19 ++++ .../GetById/GetContactByIdEndpoint.cs | 1 + .../Contacts/Update/UpdateContactEndpoint.cs | 1 + backend/WebApi/Core/Constants/Pagination.cs | 27 ++++++ backend/WebApi/Core/{ => Constants}/Routes.cs | 11 ++- backend/WebApi/Core/{ => Constants}/Tags.cs | 2 +- backend/WebApi/Core/HttpFiles/Contacts.http | 4 + 18 files changed, 394 insertions(+), 8 deletions(-) create mode 100644 backend/Application/Contacts/Get/GetContacts.cs create mode 100644 backend/Application/Contacts/Get/GetContactsQuery.cs create mode 100644 backend/Domain/Core/Pagination/Page.cs create mode 100644 backend/Domain/Core/Pagination/PageSize.cs create mode 100644 backend/Domain/Core/Pagination/PagedList.cs create mode 100644 backend/Domain/Core/Pagination/PaginationErrors.cs create mode 100644 backend/WebApi/Contacts/Get/GetContactsEndpoint.cs create mode 100644 backend/WebApi/Contacts/Get/GetContactsRequest.cs create mode 100644 backend/WebApi/Core/Constants/Pagination.cs rename backend/WebApi/Core/{ => Constants}/Routes.cs (85%) rename backend/WebApi/Core/{ => Constants}/Tags.cs (89%) diff --git a/.editorconfig b/.editorconfig index e7226a0..6a404a5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -158,7 +158,6 @@ dotnet_diagnostic.S1135.severity = none # S2326: Unused type parameters should be removed dotnet_diagnostic.S2326.severity = none -[**/Error.cs] # CA1716 Identifiers should not match keywords dotnet_diagnostic.CA1716.severity = none diff --git a/backend/Application/Contacts/Get/GetContacts.cs b/backend/Application/Contacts/Get/GetContacts.cs new file mode 100644 index 0000000..a92b87e --- /dev/null +++ b/backend/Application/Contacts/Get/GetContacts.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using System.Linq.Expressions; +using Application.Core.Abstractions.Data; +using Application.Core.Abstractions.Messaging; +using Domain.Contacts; +using Domain.Core.Pagination; +using Domain.Core.Primitives; +using Microsoft.EntityFrameworkCore; + +namespace Application.Contacts.Get; + +internal sealed class GetContacts(IDbContext context) + : IQueryHandler> +{ + public async Task>> Handle( + GetContactsQuery query, + CancellationToken cancellationToken + ) + { + Result pageResult = Page.Create(query.Page); + Result pageSizeResult = PageSize.Create(query.PageSize); + + Result firstFailOrSuccess = Result.FirstFailOrSuccess(pageResult, pageSizeResult); + + if (firstFailOrSuccess.IsFailure) + { + return firstFailOrSuccess.Error; + } + + IQueryable contactsQuery = context.Contacts; + + if (!string.IsNullOrWhiteSpace(query.SearchTerm)) + { + contactsQuery = contactsQuery.Where(c => + c.FirstName.Value.Contains(query.SearchTerm) + || c.LastName.Value.Contains(query.SearchTerm) + || c.Email.Value.Contains(query.SearchTerm) + || c.PhoneNumber.Value.Contains(query.SearchTerm) + ); + } + + if (query.SortOrder?.ToLower(CultureInfo.InvariantCulture) == "desc") + { + contactsQuery = contactsQuery.OrderByDescending(GetSortProperty(query)); + } + else + { + contactsQuery = contactsQuery.OrderBy(GetSortProperty(query)); + } + + int totalCount = await contactsQuery.CountAsync(cancellationToken); + + ContactResponse[] contactResponsesPage = await contactsQuery + .Skip((pageResult.Value - 1) * pageSizeResult.Value) + .Take(pageSizeResult.Value) + .Select(c => new ContactResponse( + c.Id, + c.FirstName, + c.LastName, + c.FullName, + c.Email, + c.PhoneNumber, + c.CreatedOnUtc + )) + .ToArrayAsync(cancellationToken); + + return new PagedList( + contactResponsesPage, + pageResult.Value, + pageSizeResult.Value, + totalCount + ); + } + + private static Expression> GetSortProperty( + GetContactsQuery query + ) + { + return query.SortColumn.ToLower(CultureInfo.InvariantCulture) switch + { + "first_name" => contact => contact.FirstName.Value, + "last_name" => contact => contact.LastName.Value, + "email" => contact => contact.Email.Value, + "phone_number" => contact => contact.PhoneNumber.Value, + "created_on" => contact => contact.CreatedOnUtc, + _ => contact => contact.CreatedOnUtc, + }; + } +} diff --git a/backend/Application/Contacts/Get/GetContactsQuery.cs b/backend/Application/Contacts/Get/GetContactsQuery.cs new file mode 100644 index 0000000..f81d91b --- /dev/null +++ b/backend/Application/Contacts/Get/GetContactsQuery.cs @@ -0,0 +1,20 @@ +using Application.Core.Abstractions.Messaging; +using Domain.Core.Pagination; + +namespace Application.Contacts.Get; + +/// +/// The query for getting contacts. +/// +/// The search term. +/// The current page. +/// The page size. +/// The sort order. +/// The sort column. +public sealed record GetContactsQuery( + string? SearchTerm, + int Page, + int PageSize, + string SortColumn, + string SortOrder +) : IQuery>; diff --git a/backend/Domain/Core/Pagination/Page.cs b/backend/Domain/Core/Pagination/Page.cs new file mode 100644 index 0000000..770051c --- /dev/null +++ b/backend/Domain/Core/Pagination/Page.cs @@ -0,0 +1,43 @@ +using Domain.Core.Primitives; + +namespace Domain.Core.Pagination; + +/// +/// Creates a new instance based on the specified value. +/// +public sealed record Page +{ + private Page(int value) + { + Value = value; + } + + /// + /// Gets the page value. + /// + public int Value { get; } + + /// + /// Implicitly converts a to it's underlying integer value. + /// + /// The page value. + public static implicit operator int(Page page) + { + return page.Value; + } + + /// + /// Creates a new instance based on the specified value. + /// + /// The page value. + /// The result of the page creation process containing the page or an error. + public static Result Create(int page) + { + if (page < 1) + { + return PaginationErrors.InvalidPage; + } + + return new Page(page); + } +} diff --git a/backend/Domain/Core/Pagination/PageSize.cs b/backend/Domain/Core/Pagination/PageSize.cs new file mode 100644 index 0000000..2e5e1e7 --- /dev/null +++ b/backend/Domain/Core/Pagination/PageSize.cs @@ -0,0 +1,58 @@ +using Domain.Core.Primitives; + +namespace Domain.Core.Pagination; + +/// +/// Creates a new instance based on the specified value. +/// +public sealed record PageSize +{ + /// + /// Maximum page size. + /// + public const int MaximumPageSize = 30; + + /// + /// Minimum page size, + /// + public const int MinimumPageSize = 10; + + private PageSize(int value) + { + Value = value; + } + + /// + /// Gets the page size value. + /// + public int Value { get; } + + /// + /// Implicitly converts a instance to it's underlying integer value. + /// + /// + public static implicit operator int(PageSize pageSize) + { + return pageSize.Value; + } + + /// + /// Creates a new page size instance based on the specified value. + /// + /// The page size. + /// The result of the page size creation process containing the page size or an error. + public static Result Create(int pageSize) + { + if (pageSize < MinimumPageSize) + { + return PaginationErrors.LowerThanAllowed; + } + + if (pageSize > MaximumPageSize) + { + return PaginationErrors.GreaterThanAllowed; + } + + return new PageSize(pageSize); + } +} diff --git a/backend/Domain/Core/Pagination/PagedList.cs b/backend/Domain/Core/Pagination/PagedList.cs new file mode 100644 index 0000000..de36d49 --- /dev/null +++ b/backend/Domain/Core/Pagination/PagedList.cs @@ -0,0 +1,47 @@ +namespace Domain.Core.Pagination; + +/// +/// Generic paged list. +/// +/// The type of list. +/// Items in the list. +/// Current page. +/// Page size. +/// Total count of the items. +public sealed class PagedList( + IEnumerable items, + Page page, + PageSize pageSize, + int totalCount +) +{ + /// + /// Gets the current page. + /// + public int Page { get; } = page; + + /// + /// Gets the page size. + /// + public int PageSize { get; } = pageSize; + + /// + /// Gets the total number of items. + /// + public int TotalCount { get; } = totalCount; + + /// + /// Gets the flag indicating whether the next page exists. + /// + public bool HasNextPage => Page * PageSize < TotalCount; + + /// + /// Gets the flag indicating whether the previous page exists. + /// + public bool HasPreviousPage => Page > 1; + + /// + /// Gets the items. + /// + public IReadOnlyCollection Items { get; } = items.ToList(); +} diff --git a/backend/Domain/Core/Pagination/PaginationErrors.cs b/backend/Domain/Core/Pagination/PaginationErrors.cs new file mode 100644 index 0000000..b824201 --- /dev/null +++ b/backend/Domain/Core/Pagination/PaginationErrors.cs @@ -0,0 +1,33 @@ +using Domain.Core.Primitives; + +namespace Domain.Core.Pagination; + +/// +/// Contains pagination errors. +/// +public static class PaginationErrors +{ + /// + /// An error indicating that the page is invalid. + /// + public static Error InvalidPage => + Error.Validation("Page.InvalidPage", "Page must be greater then or equal to 1."); + + /// + /// An error indicating that the page size is greater than allowed. + /// + public static Error GreaterThanAllowed => + Error.Validation( + "PageSize.GreaterThanAllowed", + $"Maximum page size is {PageSize.MaximumPageSize}" + ); + + /// + /// An error indicating that the page size lower than allowed. + /// + public static Error LowerThanAllowed => + Error.Validation( + "PageSize.LowerThanAllowed", + $"Minimum page size is {PageSize.MinimumPageSize}" + ); +} diff --git a/backend/Domain/Domain.csproj b/backend/Domain/Domain.csproj index 50cc5e5..9f31b7d 100644 --- a/backend/Domain/Domain.csproj +++ b/backend/Domain/Domain.csproj @@ -2,7 +2,4 @@ True - - - diff --git a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs index d501f90..2d5534a 100644 --- a/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs +++ b/backend/WebApi/Contacts/Create/CreateContactEndpoint.cs @@ -4,6 +4,7 @@ using Domain.Core.Primitives; using WebApi.Contacts.GetById; using WebApi.Core; +using WebApi.Core.Constants; using WebApi.Core.Extensions; using WebApi.Core.Infrastructure; diff --git a/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs b/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs index 7868e16..badf2f2 100644 --- a/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs +++ b/backend/WebApi/Contacts/Delete/DeleteContactEndpoint.cs @@ -2,6 +2,7 @@ using Application.Core.Abstractions.Messaging; using Domain.Core.Primitives; using WebApi.Core; +using WebApi.Core.Constants; using WebApi.Core.Extensions; using WebApi.Core.Infrastructure; diff --git a/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs b/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs new file mode 100644 index 0000000..aface30 --- /dev/null +++ b/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs @@ -0,0 +1,41 @@ +using Application.Contacts; +using Application.Contacts.Get; +using Application.Core.Abstractions.Messaging; +using Domain.Core.Pagination; +using Domain.Core.Primitives; +using WebApi.Core; +using WebApi.Core.Constants; +using WebApi.Core.Extensions; +using WebApi.Core.Infrastructure; + +namespace WebApi.Contacts.Get; + +internal sealed class GetContactsEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Routes.Contacts.Get, Handler); + } + + private static async Task Handler( + [AsParameters] GetContactsRequest request, + IQueryHandler> useCase, + CancellationToken cancellationToken + ) + { + GetContactsQuery query = new( + request.SearchTerm, + request.Page, + request.PageSize, + request.SortColumn, + request.SortOrder + ); + + Result> result = await useCase.Handle( + query, + cancellationToken + ); + + return result.Match(Results.Ok, CustomResults.Problem); + } +} diff --git a/backend/WebApi/Contacts/Get/GetContactsRequest.cs b/backend/WebApi/Contacts/Get/GetContactsRequest.cs new file mode 100644 index 0000000..bb90e2e --- /dev/null +++ b/backend/WebApi/Contacts/Get/GetContactsRequest.cs @@ -0,0 +1,19 @@ +using WebApi.Core.Constants; + +namespace WebApi.Contacts.Get; + +/// +/// Represents the request for getting a list of contacts. +/// +/// The search term. +/// The current page. +/// The page size. +/// The sorting column. +/// The sorting order. +public sealed record GetContactsRequest( + string? SearchTerm, + int Page = Pagination.DefaultPage, + int PageSize = Pagination.DefaultPageSize, + string SortColumn = Pagination.DefaultSortColum, + string SortOrder = Pagination.DefaultSortOrder +); diff --git a/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs b/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs index 91554c7..74ecfe3 100644 --- a/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs +++ b/backend/WebApi/Contacts/GetById/GetContactByIdEndpoint.cs @@ -3,6 +3,7 @@ using Application.Core.Abstractions.Messaging; using Domain.Core.Primitives; using WebApi.Core; +using WebApi.Core.Constants; using WebApi.Core.Extensions; using WebApi.Core.Infrastructure; diff --git a/backend/WebApi/Contacts/Update/UpdateContactEndpoint.cs b/backend/WebApi/Contacts/Update/UpdateContactEndpoint.cs index 62e4b40..1a1a8ad 100644 --- a/backend/WebApi/Contacts/Update/UpdateContactEndpoint.cs +++ b/backend/WebApi/Contacts/Update/UpdateContactEndpoint.cs @@ -2,6 +2,7 @@ using Application.Core.Abstractions.Messaging; using Domain.Core.Primitives; using WebApi.Core; +using WebApi.Core.Constants; using WebApi.Core.Extensions; using WebApi.Core.Infrastructure; diff --git a/backend/WebApi/Core/Constants/Pagination.cs b/backend/WebApi/Core/Constants/Pagination.cs new file mode 100644 index 0000000..2db55a9 --- /dev/null +++ b/backend/WebApi/Core/Constants/Pagination.cs @@ -0,0 +1,27 @@ +namespace WebApi.Core.Constants; + +/// +/// Default pagination values. +/// +public static class Pagination +{ + /// + /// Default page. + /// + public const int DefaultPage = 1; + + /// + /// Default page size. + /// + public const int DefaultPageSize = 10; + + /// + /// Default sort order. + /// + public const string DefaultSortOrder = "asc"; + + /// + /// Default sort column. + /// + public const string DefaultSortColum = "created_on"; +} diff --git a/backend/WebApi/Core/Routes.cs b/backend/WebApi/Core/Constants/Routes.cs similarity index 85% rename from backend/WebApi/Core/Routes.cs rename to backend/WebApi/Core/Constants/Routes.cs index fe5157f..2af4f55 100644 --- a/backend/WebApi/Core/Routes.cs +++ b/backend/WebApi/Core/Constants/Routes.cs @@ -1,4 +1,4 @@ -namespace WebApi.Core; +namespace WebApi.Core.Constants; /// /// Provides route definitions for the application's endpoints. @@ -16,15 +16,20 @@ public static class Contacts private const string Base = "contacts"; /// - /// The route used for creating a new contact. + /// The route used for retrieving a list of contacts. /// - public const string Create = Base; + public const string Get = Base; /// /// The route used for retrieving contact by an identifier. /// public const string GetById = $"{Base}/{{contactId:guid}}"; + /// + /// The route used for creating a new contact. + /// + public const string Create = Base; + /// /// The route used for deleting a contact. /// diff --git a/backend/WebApi/Core/Tags.cs b/backend/WebApi/Core/Constants/Tags.cs similarity index 89% rename from backend/WebApi/Core/Tags.cs rename to backend/WebApi/Core/Constants/Tags.cs index 3ecfa2b..d51fd2e 100644 --- a/backend/WebApi/Core/Tags.cs +++ b/backend/WebApi/Core/Constants/Tags.cs @@ -1,4 +1,4 @@ -namespace WebApi.Core; +namespace WebApi.Core.Constants; /// /// Provides string constants for endpoint tags used in API documentation and grouping. diff --git a/backend/WebApi/Core/HttpFiles/Contacts.http b/backend/WebApi/Core/HttpFiles/Contacts.http index bc6ecca..239c3e3 100644 --- a/backend/WebApi/Core/HttpFiles/Contacts.http +++ b/backend/WebApi/Core/HttpFiles/Contacts.http @@ -1,5 +1,9 @@ @HostAddress = http://localhost:5111 +### Get contacts + +GET {{HostAddress}}/api/v1/contacts?searchTerm=&page=1&pageSize=10&sortColumn=created_on&sortOrder=asc + ### Get contact by Id GET {{HostAddress}}/api/v1/contacts/81023bac-163f-4eb0-bfb5-a834ef213d16 From 1ec2d62c633f6d64caf879e4546ef6a4bb0cf385 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:46:41 +0100 Subject: [PATCH 20/46] remove Value accessor from equality checks (#32) --- backend/Application/Contacts/Create/CreateContact.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Application/Contacts/Create/CreateContact.cs b/backend/Application/Contacts/Create/CreateContact.cs index d368e08..f8d7c72 100644 --- a/backend/Application/Contacts/Create/CreateContact.cs +++ b/backend/Application/Contacts/Create/CreateContact.cs @@ -33,7 +33,7 @@ CancellationToken cancellationToken if ( await context.Contacts.AnyAsync( - c => c.Email.Value == emailResult.Value, + c => c.Email == emailResult.Value, cancellationToken ) ) From 5f5a6a834139c5ce84007c9d1c621fb3d0d60700 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:49:59 +0100 Subject: [PATCH 21/46] refactor error to lambda expression (#33) --- backend/Domain/Contacts/ContactErrors.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/Domain/Contacts/ContactErrors.cs b/backend/Domain/Contacts/ContactErrors.cs index f03f690..a6c8479 100644 --- a/backend/Domain/Contacts/ContactErrors.cs +++ b/backend/Domain/Contacts/ContactErrors.cs @@ -18,13 +18,11 @@ public static class ContactErrors /// /// The unique identifier of the contact. /// - public static Error NotFoundById(Guid contactId) - { - return Error.NotFound( + public static Error NotFoundById(Guid contactId) => + Error.NotFound( "Contact.NotFoundById", $"The contact with the Id = {contactId} was not found." ); - } /// /// Contains the first name errors. From 8048a294b1cac5c3e65ecfaae5cffdff7f83c32e Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sun, 30 Nov 2025 23:52:00 +0100 Subject: [PATCH 22/46] test: add Domain unit tests (#37) * add Contact unit tests * rename Contact unit test classes * add FirstName unit tests * refactor test classes * add LastName unit tests * change Failure to Error in test names * add Email unit tests * add PhoneNumber unit tests * add Page unit tests * add PageSize unit tests * add PagedList unit tests --- .editorconfig | 3 + .../UnitTests/Domain/Contacts/ContactTests.cs | 48 +++++++++++ tests/UnitTests/Domain/Contacts/EmailTests.cs | 56 ++++++++++++ .../Domain/Contacts/FirstNameTests.cs | 44 ++++++++++ .../Domain/Contacts/LastNameTests.cs | 44 ++++++++++ .../Domain/Contacts/PhoneNumberTests.cs | 68 +++++++++++++++ .../Domain/Pagination/PageSizeTests.cs | 43 ++++++++++ .../UnitTests/Domain/Pagination/PageTests.cs | 31 +++++++ .../Domain/Pagination/PagedListTests.cs | 85 +++++++++++++++++++ tests/UnitTests/UnitTests.csproj | 9 +- 10 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 tests/UnitTests/Domain/Contacts/ContactTests.cs create mode 100644 tests/UnitTests/Domain/Contacts/EmailTests.cs create mode 100644 tests/UnitTests/Domain/Contacts/FirstNameTests.cs create mode 100644 tests/UnitTests/Domain/Contacts/LastNameTests.cs create mode 100644 tests/UnitTests/Domain/Contacts/PhoneNumberTests.cs create mode 100644 tests/UnitTests/Domain/Pagination/PageSizeTests.cs create mode 100644 tests/UnitTests/Domain/Pagination/PageTests.cs create mode 100644 tests/UnitTests/Domain/Pagination/PagedListTests.cs diff --git a/.editorconfig b/.editorconfig index 6a404a5..39e9fa4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -161,6 +161,9 @@ dotnet_diagnostic.S2326.severity = none # CA1716 Identifiers should not match keywords dotnet_diagnostic.CA1716.severity = none +# CA1707: Identifiers should not contain underscores +dotnet_diagnostic.CA1707.severity = none + [**/Result.cs] # CA1000: Do not declare static members on generic types dotnet_diagnostic.CA1000.severity = none diff --git a/tests/UnitTests/Domain/Contacts/ContactTests.cs b/tests/UnitTests/Domain/Contacts/ContactTests.cs new file mode 100644 index 0000000..62bfe2f --- /dev/null +++ b/tests/UnitTests/Domain/Contacts/ContactTests.cs @@ -0,0 +1,48 @@ +using Domain.Contacts; + +namespace UnitTests.Domain.Contacts; + +public class ContactTests +{ + private static readonly FirstName _firstName = FirstName.Create("John").Value; + private static readonly LastName _lastName = LastName.Create("Doe").Value; + private static readonly Email _email = Email.Create("jdoe@gmail.com").Value; + private static readonly PhoneNumber _phoneNumber = PhoneNumber + .Create("0919876543") + .Value; + private static string _fullName => $"{_firstName.Value} {_lastName.Value}"; + + [Fact] + public void Create_Should_ReturnContact_WithValidData() + { + Contact contact = Contact.Create(_firstName, _lastName, _email, _phoneNumber); + + Assert.NotNull(contact); + Assert.NotEqual(Guid.Empty, contact.Id); + Assert.Equal(_firstName, contact.FirstName); + Assert.Equal(_lastName, contact.LastName); + Assert.Equal(_email, contact.Email); + Assert.Equal(_phoneNumber, contact.PhoneNumber); + Assert.Equal(_fullName, contact.FullName); + } + + [Fact] + public void Update_Should_ChangeAllProperties() + { + Contact contact = Contact.Create(_firstName, _lastName, _email, _phoneNumber); + + FirstName newFirstName = FirstName.Create("Ada").Value; + LastName newLastName = LastName.Create("Lovelace").Value; + Email newEmail = Email.Create("alovelace@gmail.com").Value; + PhoneNumber newPhoneNumber = PhoneNumber.Create("0913456789").Value; + string newFullName = $"{newFirstName.Value} {newLastName.Value}"; + + contact.UpdateContact(newFirstName, newLastName, newEmail, newPhoneNumber); + + Assert.Equal(newFirstName, contact.FirstName); + Assert.Equal(newLastName, contact.LastName); + Assert.Equal(newEmail, contact.Email); + Assert.Equal(newPhoneNumber, contact.PhoneNumber); + Assert.Equal(newFullName, contact.FullName); + } +} diff --git a/tests/UnitTests/Domain/Contacts/EmailTests.cs b/tests/UnitTests/Domain/Contacts/EmailTests.cs new file mode 100644 index 0000000..b4b53ba --- /dev/null +++ b/tests/UnitTests/Domain/Contacts/EmailTests.cs @@ -0,0 +1,56 @@ +using Domain.Contacts; +using Domain.Core.Primitives; + +namespace UnitTests.Domain.Contacts; + +public class EmailTests +{ + [Fact] + public void Create_Should_ReturnSuccess_WithValidInput() + { + string input = "jdoe@gmail.com"; + + Result result = Email.Create(input); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(input, result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_Should_ReturnError_WithNullOrEmptyInput(string? input) + { + Result result = Email.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.Email.NullOrEmpty, result.Error); + } + + [Fact] + public void Create_Should_ReturnError_WithTooLongInput() + { + string input = new('a', Email.MaxLength + 1); + + Result result = Email.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.Email.LongerThanAllowed, result.Error); + } + + [Theory] + [InlineData("jdoe")] + [InlineData("jdoegmail.com")] + public void Create_Should_ReturnError_WithInvalidFormatInput(string? input) + { + Result result = Email.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.Email.InvalidFormat, result.Error); + } +} diff --git a/tests/UnitTests/Domain/Contacts/FirstNameTests.cs b/tests/UnitTests/Domain/Contacts/FirstNameTests.cs new file mode 100644 index 0000000..f25deec --- /dev/null +++ b/tests/UnitTests/Domain/Contacts/FirstNameTests.cs @@ -0,0 +1,44 @@ +using Domain.Contacts; +using Domain.Core.Primitives; + +namespace UnitTests.Domain.Contacts; + +public class FirstNameTests +{ + [Fact] + public void Create_Should_ReturnSuccess_WithValidInput() + { + string input = "John"; + + Result result = FirstName.Create(input); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(input, result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_Should_ReturnError_WithNullOrEmptyInput(string? input) + { + Result result = FirstName.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.FirstName.NullOrEmpty, result.Error); + } + + [Fact] + public void Create_Should_ReturnError_WithTooLongInput() + { + string input = new('a', FirstName.MaxLength + 1); + + Result result = FirstName.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.FirstName.LongerThanAllowed, result.Error); + } +} diff --git a/tests/UnitTests/Domain/Contacts/LastNameTests.cs b/tests/UnitTests/Domain/Contacts/LastNameTests.cs new file mode 100644 index 0000000..4b73ee6 --- /dev/null +++ b/tests/UnitTests/Domain/Contacts/LastNameTests.cs @@ -0,0 +1,44 @@ +using Domain.Contacts; +using Domain.Core.Primitives; + +namespace UnitTests.Domain.Contacts; + +public class LastNameTests +{ + [Fact] + public void Create_Should_ReturnSuccess_WithValidInput() + { + string input = "Doe"; + + Result result = LastName.Create(input); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(input, result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_Should_ReturnError_WithNullOrEmptyInput(string? input) + { + Result result = LastName.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.LastName.NullOrEmpty, result.Error); + } + + [Fact] + public void Create_Should_ReturnError_WithTooLongInput() + { + string input = new('a', LastName.MaxLength + 1); + + Result result = LastName.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.LastName.LongerThanAllowed, result.Error); + } +} diff --git a/tests/UnitTests/Domain/Contacts/PhoneNumberTests.cs b/tests/UnitTests/Domain/Contacts/PhoneNumberTests.cs new file mode 100644 index 0000000..009bac8 --- /dev/null +++ b/tests/UnitTests/Domain/Contacts/PhoneNumberTests.cs @@ -0,0 +1,68 @@ +using Domain.Contacts; +using Domain.Core.Primitives; + +namespace UnitTests.Domain.Contacts; + +public class PhoneNumberTests +{ + [Fact] + public void Create_Should_ReturnSuccess_WithValidInput() + { + string input = "0919876543"; + + Result result = PhoneNumber.Create(input); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(input, result.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_Should_ReturnError_WithNullOrEmptyInput(string? input) + { + Result result = PhoneNumber.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.PhoneNumber.NullOrEmpty, result.Error); + } + + [Fact] + public void Create_Should_ReturnError_WithTooLongInput() + { + string input = new('a', PhoneNumber.MaxLength + 1); + + Result result = PhoneNumber.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.PhoneNumber.LongerThanAllowed, result.Error); + } + + [Fact] + public void Create_Should_ReturnError_WithTooShortInput() + { + string input = new('a', PhoneNumber.MinLength - 1); + + Result result = PhoneNumber.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.PhoneNumber.ShorterThanAllowed, result.Error); + } + + [Fact] + public void Create_Should_ReturnError_WithInvalidFormatInput() + { + string input = "091987654a"; + + Result result = PhoneNumber.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(ContactErrors.PhoneNumber.InvalidFormat, result.Error); + } +} diff --git a/tests/UnitTests/Domain/Pagination/PageSizeTests.cs b/tests/UnitTests/Domain/Pagination/PageSizeTests.cs new file mode 100644 index 0000000..c346132 --- /dev/null +++ b/tests/UnitTests/Domain/Pagination/PageSizeTests.cs @@ -0,0 +1,43 @@ +using Domain.Core.Pagination; +using Domain.Core.Primitives; + +namespace UnitTests.Domain.Pagination; + +public class PageSizeTests +{ + [Fact] + public void Create_Should_ReturnSuccess_WithValidInput() + { + int input = 10; + + Result result = PageSize.Create(input); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(input, result.Value); + } + + [Fact] + public void Create_Should_ReturnError_WithTooLowInput() + { + int input = PageSize.MinimumPageSize - 1; + + Result result = PageSize.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(PaginationErrors.LowerThanAllowed, result.Error); + } + + [Fact] + public void Create_Should_ReturnError_WithTooHighInput() + { + int input = PageSize.MaximumPageSize + 1; + + Result result = PageSize.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(PaginationErrors.GreaterThanAllowed, result.Error); + } +} diff --git a/tests/UnitTests/Domain/Pagination/PageTests.cs b/tests/UnitTests/Domain/Pagination/PageTests.cs new file mode 100644 index 0000000..bd48f46 --- /dev/null +++ b/tests/UnitTests/Domain/Pagination/PageTests.cs @@ -0,0 +1,31 @@ +using Domain.Core.Pagination; +using Domain.Core.Primitives; + +namespace UnitTests.Domain.Pagination; + +public class PageTests +{ + [Fact] + public void Create_Should_ReturnSuccess_WithValidInput() + { + int input = 1; + + Result result = Page.Create(input); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(input, result.Value); + } + + [Fact] + public void Create_Should_ReturnError_WithTooLowInput() + { + int input = 0; + + Result result = Page.Create(input); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal(PaginationErrors.InvalidPage, result.Error); + } +} diff --git a/tests/UnitTests/Domain/Pagination/PagedListTests.cs b/tests/UnitTests/Domain/Pagination/PagedListTests.cs new file mode 100644 index 0000000..e11b5dd --- /dev/null +++ b/tests/UnitTests/Domain/Pagination/PagedListTests.cs @@ -0,0 +1,85 @@ +using Domain.Core.Pagination; +using Domain.Core.Primitives; + +namespace UnitTests.Domain.Pagination; + +public class PagedListTests +{ + private static readonly List _items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + private static readonly Result _firstPage = Page.Create(1); + private static readonly Result _pageSize = PageSize.Create(10); + private static readonly int _totalCount = _items.Count; + + [Fact] + public void Constructor_Should_SetProperties() + { + PagedList pagedList = new( + _items, + _firstPage.Value, + _pageSize.Value, + _totalCount + ); + + Assert.Equal(_firstPage.Value, pagedList.Page); + Assert.Equal(_pageSize.Value, pagedList.PageSize); + Assert.Equal(_totalCount, pagedList.TotalCount); + + Assert.Equal(_items.Count, pagedList.Items.Count); + Assert.Equal(_items, pagedList.Items); + } + + [Fact] + public void HasNextPage_Should_ReturnTrue_WhenNextPageExists() + { + PagedList pagedList = new( + _items, + _firstPage.Value, + _pageSize.Value, + _totalCount + ); + + Assert.True(pagedList.HasNextPage); + } + + [Fact] + public void HasNextPage_Should_ReturnFalse_WhenNextPageDoesNotExist() + { + Result lastPage = Page.Create(2); + + PagedList pagedList = new( + _items, + lastPage.Value, + _pageSize.Value, + _totalCount + ); + + Assert.False(pagedList.HasNextPage); + } + + [Fact] + public void HasPreviousPage_Should_ReturnFalse_WhenOnFirstPage() + { + PagedList pagedList = new( + _items, + _firstPage.Value, + _pageSize.Value, + _totalCount + ); + + Assert.False(pagedList.HasPreviousPage); + } + + [Fact] + public void HasPreviousPage_Should_ReturnTrue_WhenNotOnFirstPage() + { + Result secondPage = Page.Create(2); + PagedList pagedList = new( + _items, + secondPage.Value, + _pageSize.Value, + _totalCount + ); + + Assert.True(pagedList.HasPreviousPage); + } +} diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj index 697e6aa..68d9a4d 100644 --- a/tests/UnitTests/UnitTests.csproj +++ b/tests/UnitTests/UnitTests.csproj @@ -1,16 +1,13 @@  - - net9.0 - enable - enable - false - + + + From 4498ff29dc53fedaa3e1a17fba6f52c7533d634f Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:38:44 +0100 Subject: [PATCH 23/46] refactor: rename UnitTests project to Domain.UnitTests (#40) --- PhoneForge.sln | 12 ++++++------ .../Contacts/ContactTests.cs | 2 +- .../Contacts/EmailTests.cs | 2 +- .../Contacts/FirstNameTests.cs | 2 +- .../Contacts/LastNameTests.cs | 2 +- .../Contacts/PhoneNumberTests.cs | 2 +- .../Domain.UnitTests.csproj} | 0 .../Pagination/PageSizeTests.cs | 2 +- .../Pagination/PageTests.cs | 2 +- .../Pagination/PagedListTests.cs | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) rename tests/{UnitTests/Domain => Domain.UnitTests}/Contacts/ContactTests.cs (98%) rename tests/{UnitTests/Domain => Domain.UnitTests}/Contacts/EmailTests.cs (97%) rename tests/{UnitTests/Domain => Domain.UnitTests}/Contacts/FirstNameTests.cs (96%) rename tests/{UnitTests/Domain => Domain.UnitTests}/Contacts/LastNameTests.cs (96%) rename tests/{UnitTests/Domain => Domain.UnitTests}/Contacts/PhoneNumberTests.cs (98%) rename tests/{UnitTests/UnitTests.csproj => Domain.UnitTests/Domain.UnitTests.csproj} (100%) rename tests/{UnitTests/Domain => Domain.UnitTests}/Pagination/PageSizeTests.cs (96%) rename tests/{UnitTests/Domain => Domain.UnitTests}/Pagination/PageTests.cs (94%) rename tests/{UnitTests/Domain => Domain.UnitTests}/Pagination/PagedListTests.cs (98%) diff --git a/PhoneForge.sln b/PhoneForge.sln index a307a7a..9e1fa71 100644 --- a/PhoneForge.sln +++ b/PhoneForge.sln @@ -18,7 +18,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi", "backend\WebApi\We EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Console", "frontend\Console\Console.csproj", "{15D2C271-E237-D10C-AFFC-2FB01BAF09C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{9D1DD2CD-7B04-4472-4377-027563F356CA}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain.UnitTests", "tests\Domain.UnitTests\Domain.UnitTests.csproj", "{F566C582-138F-7EAC-84B2-26BD8903E78D}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{089100B1-113F-4E66-888A-E83F3999EAFD}" ProjectSection(SolutionItems) = preProject @@ -54,10 +54,10 @@ Global {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|Any CPU.Build.0 = Release|Any CPU - {9D1DD2CD-7B04-4472-4377-027563F356CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9D1DD2CD-7B04-4472-4377-027563F356CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9D1DD2CD-7B04-4472-4377-027563F356CA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9D1DD2CD-7B04-4472-4377-027563F356CA}.Release|Any CPU.Build.0 = Release|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -68,7 +68,7 @@ Global {BAC07D44-E017-A073-6C9A-C5B760BE66C1} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} {FF832594-E403-2B1F-49E8-55A6A5C58268} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} {15D2C271-E237-D10C-AFFC-2FB01BAF09C3} = {CE609670-85B9-0D16-FB54-ED063D5D8A6D} - {9D1DD2CD-7B04-4472-4377-027563F356CA} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {F566C582-138F-7EAC-84B2-26BD8903E78D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {646D0DF0-B048-4BFA-9672-6C5C78680E95} diff --git a/tests/UnitTests/Domain/Contacts/ContactTests.cs b/tests/Domain.UnitTests/Contacts/ContactTests.cs similarity index 98% rename from tests/UnitTests/Domain/Contacts/ContactTests.cs rename to tests/Domain.UnitTests/Contacts/ContactTests.cs index 62bfe2f..188f8bb 100644 --- a/tests/UnitTests/Domain/Contacts/ContactTests.cs +++ b/tests/Domain.UnitTests/Contacts/ContactTests.cs @@ -1,6 +1,6 @@ using Domain.Contacts; -namespace UnitTests.Domain.Contacts; +namespace Domain.UnitTests.Contacts; public class ContactTests { diff --git a/tests/UnitTests/Domain/Contacts/EmailTests.cs b/tests/Domain.UnitTests/Contacts/EmailTests.cs similarity index 97% rename from tests/UnitTests/Domain/Contacts/EmailTests.cs rename to tests/Domain.UnitTests/Contacts/EmailTests.cs index b4b53ba..8419326 100644 --- a/tests/UnitTests/Domain/Contacts/EmailTests.cs +++ b/tests/Domain.UnitTests/Contacts/EmailTests.cs @@ -1,7 +1,7 @@ using Domain.Contacts; using Domain.Core.Primitives; -namespace UnitTests.Domain.Contacts; +namespace Domain.UnitTests.Contacts; public class EmailTests { diff --git a/tests/UnitTests/Domain/Contacts/FirstNameTests.cs b/tests/Domain.UnitTests/Contacts/FirstNameTests.cs similarity index 96% rename from tests/UnitTests/Domain/Contacts/FirstNameTests.cs rename to tests/Domain.UnitTests/Contacts/FirstNameTests.cs index f25deec..5903024 100644 --- a/tests/UnitTests/Domain/Contacts/FirstNameTests.cs +++ b/tests/Domain.UnitTests/Contacts/FirstNameTests.cs @@ -1,7 +1,7 @@ using Domain.Contacts; using Domain.Core.Primitives; -namespace UnitTests.Domain.Contacts; +namespace Domain.UnitTests.Contacts; public class FirstNameTests { diff --git a/tests/UnitTests/Domain/Contacts/LastNameTests.cs b/tests/Domain.UnitTests/Contacts/LastNameTests.cs similarity index 96% rename from tests/UnitTests/Domain/Contacts/LastNameTests.cs rename to tests/Domain.UnitTests/Contacts/LastNameTests.cs index 4b73ee6..13fb04a 100644 --- a/tests/UnitTests/Domain/Contacts/LastNameTests.cs +++ b/tests/Domain.UnitTests/Contacts/LastNameTests.cs @@ -1,7 +1,7 @@ using Domain.Contacts; using Domain.Core.Primitives; -namespace UnitTests.Domain.Contacts; +namespace Domain.UnitTests.Contacts; public class LastNameTests { diff --git a/tests/UnitTests/Domain/Contacts/PhoneNumberTests.cs b/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs similarity index 98% rename from tests/UnitTests/Domain/Contacts/PhoneNumberTests.cs rename to tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs index 009bac8..10f107a 100644 --- a/tests/UnitTests/Domain/Contacts/PhoneNumberTests.cs +++ b/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs @@ -1,7 +1,7 @@ using Domain.Contacts; using Domain.Core.Primitives; -namespace UnitTests.Domain.Contacts; +namespace Domain.UnitTests.Contacts; public class PhoneNumberTests { diff --git a/tests/UnitTests/UnitTests.csproj b/tests/Domain.UnitTests/Domain.UnitTests.csproj similarity index 100% rename from tests/UnitTests/UnitTests.csproj rename to tests/Domain.UnitTests/Domain.UnitTests.csproj diff --git a/tests/UnitTests/Domain/Pagination/PageSizeTests.cs b/tests/Domain.UnitTests/Pagination/PageSizeTests.cs similarity index 96% rename from tests/UnitTests/Domain/Pagination/PageSizeTests.cs rename to tests/Domain.UnitTests/Pagination/PageSizeTests.cs index c346132..06b76e1 100644 --- a/tests/UnitTests/Domain/Pagination/PageSizeTests.cs +++ b/tests/Domain.UnitTests/Pagination/PageSizeTests.cs @@ -1,7 +1,7 @@ using Domain.Core.Pagination; using Domain.Core.Primitives; -namespace UnitTests.Domain.Pagination; +namespace Domain.UnitTests.Pagination; public class PageSizeTests { diff --git a/tests/UnitTests/Domain/Pagination/PageTests.cs b/tests/Domain.UnitTests/Pagination/PageTests.cs similarity index 94% rename from tests/UnitTests/Domain/Pagination/PageTests.cs rename to tests/Domain.UnitTests/Pagination/PageTests.cs index bd48f46..8efcec1 100644 --- a/tests/UnitTests/Domain/Pagination/PageTests.cs +++ b/tests/Domain.UnitTests/Pagination/PageTests.cs @@ -1,7 +1,7 @@ using Domain.Core.Pagination; using Domain.Core.Primitives; -namespace UnitTests.Domain.Pagination; +namespace Domain.UnitTests.Pagination; public class PageTests { diff --git a/tests/UnitTests/Domain/Pagination/PagedListTests.cs b/tests/Domain.UnitTests/Pagination/PagedListTests.cs similarity index 98% rename from tests/UnitTests/Domain/Pagination/PagedListTests.cs rename to tests/Domain.UnitTests/Pagination/PagedListTests.cs index e11b5dd..927a90e 100644 --- a/tests/UnitTests/Domain/Pagination/PagedListTests.cs +++ b/tests/Domain.UnitTests/Pagination/PagedListTests.cs @@ -1,7 +1,7 @@ using Domain.Core.Pagination; using Domain.Core.Primitives; -namespace UnitTests.Domain.Pagination; +namespace Domain.UnitTests.Pagination; public class PagedListTests { From ea13c8691c765695da3eb56bf8bf10fe5c64239d Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:11:14 +0100 Subject: [PATCH 24/46] feat: add GetContactsResponse contract (#43) --- .../Application/Contacts/Get/GetContacts.cs | 16 +++++++++++--- .../Contacts/Get/GetContactsQuery.cs | 3 +-- .../Contacts/Get/GetContactsResponse.cs | 21 +++++++++++++++++++ backend/Domain/Core/Pagination/PagedList.cs | 5 +++++ .../Contacts/Get/GetContactsEndpoint.cs | 6 ++---- 5 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 backend/Application/Contacts/Get/GetContactsResponse.cs diff --git a/backend/Application/Contacts/Get/GetContacts.cs b/backend/Application/Contacts/Get/GetContacts.cs index a92b87e..9f3c654 100644 --- a/backend/Application/Contacts/Get/GetContacts.cs +++ b/backend/Application/Contacts/Get/GetContacts.cs @@ -10,9 +10,9 @@ namespace Application.Contacts.Get; internal sealed class GetContacts(IDbContext context) - : IQueryHandler> + : IQueryHandler { - public async Task>> Handle( + public async Task> Handle( GetContactsQuery query, CancellationToken cancellationToken ) @@ -64,12 +64,22 @@ CancellationToken cancellationToken )) .ToArrayAsync(cancellationToken); - return new PagedList( + PagedList pagedList = new( contactResponsesPage, pageResult.Value, pageSizeResult.Value, totalCount ); + + return new GetContactsResponse( + pagedList.Page, + pagedList.PageSize, + pagedList.TotalCount, + pagedList.TotalPages, + pagedList.HasNextPage, + pagedList.HasPreviousPage, + pagedList.Items + ); } private static Expression> GetSortProperty( diff --git a/backend/Application/Contacts/Get/GetContactsQuery.cs b/backend/Application/Contacts/Get/GetContactsQuery.cs index f81d91b..3d2d100 100644 --- a/backend/Application/Contacts/Get/GetContactsQuery.cs +++ b/backend/Application/Contacts/Get/GetContactsQuery.cs @@ -1,5 +1,4 @@ using Application.Core.Abstractions.Messaging; -using Domain.Core.Pagination; namespace Application.Contacts.Get; @@ -17,4 +16,4 @@ public sealed record GetContactsQuery( int PageSize, string SortColumn, string SortOrder -) : IQuery>; +) : IQuery; diff --git a/backend/Application/Contacts/Get/GetContactsResponse.cs b/backend/Application/Contacts/Get/GetContactsResponse.cs new file mode 100644 index 0000000..19c853f --- /dev/null +++ b/backend/Application/Contacts/Get/GetContactsResponse.cs @@ -0,0 +1,21 @@ +namespace Application.Contacts.Get; + +/// +/// A response for returning paged list of contacts. +/// +/// Current page. +/// Page size. +/// Total number of items in all pages. +/// Total number of pages. +/// Indicates whether next page exists. +/// Indicates whether previous page exists. +/// List of contact items. +public record GetContactsResponse( + int Page, + int PageSize, + int TotalCount, + int TotalPages, + bool HasNextPage, + bool HasPreviousPage, + IReadOnlyCollection Items +); diff --git a/backend/Domain/Core/Pagination/PagedList.cs b/backend/Domain/Core/Pagination/PagedList.cs index de36d49..ae44528 100644 --- a/backend/Domain/Core/Pagination/PagedList.cs +++ b/backend/Domain/Core/Pagination/PagedList.cs @@ -30,6 +30,11 @@ int totalCount /// public int TotalCount { get; } = totalCount; + /// + /// Gets the total number of pages. + /// + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + /// /// Gets the flag indicating whether the next page exists. /// diff --git a/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs b/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs index aface30..905a18a 100644 --- a/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs +++ b/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs @@ -1,7 +1,5 @@ -using Application.Contacts; using Application.Contacts.Get; using Application.Core.Abstractions.Messaging; -using Domain.Core.Pagination; using Domain.Core.Primitives; using WebApi.Core; using WebApi.Core.Constants; @@ -19,7 +17,7 @@ public void MapEndpoint(IEndpointRouteBuilder app) private static async Task Handler( [AsParameters] GetContactsRequest request, - IQueryHandler> useCase, + IQueryHandler useCase, CancellationToken cancellationToken ) { @@ -31,7 +29,7 @@ CancellationToken cancellationToken request.SortOrder ); - Result> result = await useCase.Handle( + Result result = await useCase.Handle( query, cancellationToken ); From 6b3fa1313932d709207a5c0a3f331e7479b95b61 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sun, 7 Dec 2025 19:47:49 +0100 Subject: [PATCH 25/46] test: add integration tests (#44) * add CreateContact test * add test data seeding and CreateContact tests for failure conditions * add shared database collection * add Testing environment * refactor test data into a separate project * rename CreateContact folder to Create in order to match application structure * add GetContactById tests * add error assertions to GetContactyById test * add DeleteContact tests * refactor common error assertions to AssertResponseErrorDetails method * refactor tests to use migrations * add GetContacts test * add GetContacts tests * add UpdateContact tests * update NuGet packages --- .editorconfig | 6 ++ Directory.Packages.props | 56 +++++------ PhoneForge.sln | 83 ++++++++++++++++ backend/Application/Application.csproj | 3 + .../Application/Core/DependencyInjection.cs | 3 + backend/WebApi/Core/HttpFiles/Contacts.http | 4 +- tests/Domain.UnitTests/Assembly.cs | 3 + .../Contacts/CreateContactTests.cs | 78 +++++++++++++++ .../Contacts/DeleteContactTests.cs | 49 ++++++++++ .../Contacts/GetContactByIdTests.cs | 41 ++++++++ .../Contacts/GetContactsTests.cs | 88 +++++++++++++++++ .../Contacts/UpdateContactTests.cs | 96 +++++++++++++++++++ .../Core/Abstractions/BaseIntegrationTest.cs | 45 +++++++++ .../IntegrationTestWebAppFactory.cs | 41 ++++++++ .../Core/Abstractions/SharedTestCollection.cs | 4 + .../Core/Contracts/CustomProblemDetails.cs | 10 ++ .../HttpResponseMessageExtensions.cs | 30 ++++++ .../IntegrationTests/IntegrationTests.csproj | 23 +++++ .../IntegrationTests/integrationsettings.json | 5 + tests/TestData/Assembly.cs | 3 + tests/TestData/Contacts/ContactData.cs | 14 +++ .../Create/CreateContactInvalidData.cs | 32 +++++++ .../Create/CreateContactRequestData.cs | 56 +++++++++++ .../Contacts/Create/CreateContactValidData.cs | 12 +++ tests/TestData/Contacts/EmailData.cs | 12 +++ tests/TestData/Contacts/FirstNameData.cs | 15 +++ .../Contacts/Get/GetContactsInvalidData.cs | 25 +++++ .../Contacts/Get/GetContactsRequestData.cs | 32 +++++++ .../Contacts/Get/GetContactsValidData.cs | 19 ++++ tests/TestData/Contacts/LastNameData.cs | 15 +++ tests/TestData/Contacts/PhoneNumberData.cs | 20 ++++ .../Update/UpdateContactInvalidData.cs | 32 +++++++ .../Update/UpdateContactRequestData.cs | 56 +++++++++++ .../Contacts/Update/UpdateContactValidData.cs | 12 +++ .../UpdateContactWithNonExistingIdData.cs | 21 ++++ tests/TestData/Pagination/PageData.cs | 10 ++ tests/TestData/Pagination/PageSizeData.cs | 13 +++ tests/TestData/Seeding/TestDataSeeder.cs | 35 +++++++ tests/TestData/TestData.csproj | 9 ++ 39 files changed, 1082 insertions(+), 29 deletions(-) create mode 100644 tests/Domain.UnitTests/Assembly.cs create mode 100644 tests/IntegrationTests/Contacts/CreateContactTests.cs create mode 100644 tests/IntegrationTests/Contacts/DeleteContactTests.cs create mode 100644 tests/IntegrationTests/Contacts/GetContactByIdTests.cs create mode 100644 tests/IntegrationTests/Contacts/GetContactsTests.cs create mode 100644 tests/IntegrationTests/Contacts/UpdateContactTests.cs create mode 100644 tests/IntegrationTests/Core/Abstractions/BaseIntegrationTest.cs create mode 100644 tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs create mode 100644 tests/IntegrationTests/Core/Abstractions/SharedTestCollection.cs create mode 100644 tests/IntegrationTests/Core/Contracts/CustomProblemDetails.cs create mode 100644 tests/IntegrationTests/Core/Extensions/HttpResponseMessageExtensions.cs create mode 100644 tests/IntegrationTests/IntegrationTests.csproj create mode 100644 tests/IntegrationTests/integrationsettings.json create mode 100644 tests/TestData/Assembly.cs create mode 100644 tests/TestData/Contacts/ContactData.cs create mode 100644 tests/TestData/Contacts/Create/CreateContactInvalidData.cs create mode 100644 tests/TestData/Contacts/Create/CreateContactRequestData.cs create mode 100644 tests/TestData/Contacts/Create/CreateContactValidData.cs create mode 100644 tests/TestData/Contacts/EmailData.cs create mode 100644 tests/TestData/Contacts/FirstNameData.cs create mode 100644 tests/TestData/Contacts/Get/GetContactsInvalidData.cs create mode 100644 tests/TestData/Contacts/Get/GetContactsRequestData.cs create mode 100644 tests/TestData/Contacts/Get/GetContactsValidData.cs create mode 100644 tests/TestData/Contacts/LastNameData.cs create mode 100644 tests/TestData/Contacts/PhoneNumberData.cs create mode 100644 tests/TestData/Contacts/Update/UpdateContactInvalidData.cs create mode 100644 tests/TestData/Contacts/Update/UpdateContactRequestData.cs create mode 100644 tests/TestData/Contacts/Update/UpdateContactValidData.cs create mode 100644 tests/TestData/Contacts/Update/UpdateContactWithNonExistingIdData.cs create mode 100644 tests/TestData/Pagination/PageData.cs create mode 100644 tests/TestData/Pagination/PageSizeData.cs create mode 100644 tests/TestData/Seeding/TestDataSeeder.cs create mode 100644 tests/TestData/TestData.csproj diff --git a/.editorconfig b/.editorconfig index 39e9fa4..3b557d4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -164,6 +164,12 @@ dotnet_diagnostic.CA1716.severity = none # CA1707: Identifiers should not contain underscores dotnet_diagnostic.CA1707.severity = none +# CA1051: Do not declare visible instance fields +dotnet_diagnostic.CA1051.severity = none + +# CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = none + [**/Result.cs] # CA1000: Do not declare static members on generic types dotnet_diagnostic.CA1000.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index 747fef0..92d7aca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,28 +1,30 @@ - - true - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - + + true + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + \ No newline at end of file diff --git a/PhoneForge.sln b/PhoneForge.sln index 9e1fa71..6acbfb1 100644 --- a/PhoneForge.sln +++ b/PhoneForge.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 @@ -28,36 +29,116 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntegrationTests", "tests\IntegrationTests\IntegrationTests.csproj", "{22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestData", "tests\TestData\TestData.csproj", "{9154254D-286D-4BD9-A409-235E62E6AB99}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Debug|x64.Build.0 = Debug|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Debug|x86.Build.0 = Debug|Any CPU {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Release|Any CPU.ActiveCfg = Release|Any CPU {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Release|Any CPU.Build.0 = Release|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Release|x64.ActiveCfg = Release|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Release|x64.Build.0 = Release|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Release|x86.ActiveCfg = Release|Any CPU + {09B42AD5-BBE1-572C-B555-FF85175AD7CA}.Release|x86.Build.0 = Release|Any CPU {1AF93A02-C695-A740-C744-9453045209F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1AF93A02-C695-A740-C744-9453045209F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Debug|x64.ActiveCfg = Debug|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Debug|x64.Build.0 = Debug|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Debug|x86.ActiveCfg = Debug|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Debug|x86.Build.0 = Debug|Any CPU {1AF93A02-C695-A740-C744-9453045209F4}.Release|Any CPU.ActiveCfg = Release|Any CPU {1AF93A02-C695-A740-C744-9453045209F4}.Release|Any CPU.Build.0 = Release|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Release|x64.ActiveCfg = Release|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Release|x64.Build.0 = Release|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Release|x86.ActiveCfg = Release|Any CPU + {1AF93A02-C695-A740-C744-9453045209F4}.Release|x86.Build.0 = Release|Any CPU {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Debug|x64.Build.0 = Debug|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Debug|x86.Build.0 = Debug|Any CPU {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Release|Any CPU.Build.0 = Release|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Release|x64.ActiveCfg = Release|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Release|x64.Build.0 = Release|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Release|x86.ActiveCfg = Release|Any CPU + {BAC07D44-E017-A073-6C9A-C5B760BE66C1}.Release|x86.Build.0 = Release|Any CPU {FF832594-E403-2B1F-49E8-55A6A5C58268}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FF832594-E403-2B1F-49E8-55A6A5C58268}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Debug|x64.ActiveCfg = Debug|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Debug|x64.Build.0 = Debug|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Debug|x86.ActiveCfg = Debug|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Debug|x86.Build.0 = Debug|Any CPU {FF832594-E403-2B1F-49E8-55A6A5C58268}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF832594-E403-2B1F-49E8-55A6A5C58268}.Release|Any CPU.Build.0 = Release|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Release|x64.ActiveCfg = Release|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Release|x64.Build.0 = Release|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Release|x86.ActiveCfg = Release|Any CPU + {FF832594-E403-2B1F-49E8-55A6A5C58268}.Release|x86.Build.0 = Release|Any CPU {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|x64.ActiveCfg = Debug|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|x64.Build.0 = Debug|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|x86.ActiveCfg = Debug|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Debug|x86.Build.0 = Debug|Any CPU {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|Any CPU.Build.0 = Release|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|x64.ActiveCfg = Release|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|x64.Build.0 = Release|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|x86.ActiveCfg = Release|Any CPU + {15D2C271-E237-D10C-AFFC-2FB01BAF09C3}.Release|x86.Build.0 = Release|Any CPU {F566C582-138F-7EAC-84B2-26BD8903E78D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F566C582-138F-7EAC-84B2-26BD8903E78D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Debug|x64.ActiveCfg = Debug|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Debug|x64.Build.0 = Debug|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Debug|x86.ActiveCfg = Debug|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Debug|x86.Build.0 = Debug|Any CPU {F566C582-138F-7EAC-84B2-26BD8903E78D}.Release|Any CPU.ActiveCfg = Release|Any CPU {F566C582-138F-7EAC-84B2-26BD8903E78D}.Release|Any CPU.Build.0 = Release|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Release|x64.ActiveCfg = Release|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Release|x64.Build.0 = Release|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Release|x86.ActiveCfg = Release|Any CPU + {F566C582-138F-7EAC-84B2-26BD8903E78D}.Release|x86.Build.0 = Release|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Debug|x64.ActiveCfg = Debug|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Debug|x64.Build.0 = Debug|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Debug|x86.ActiveCfg = Debug|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Debug|x86.Build.0 = Debug|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Release|Any CPU.Build.0 = Release|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Release|x64.ActiveCfg = Release|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Release|x64.Build.0 = Release|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Release|x86.ActiveCfg = Release|Any CPU + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}.Release|x86.Build.0 = Release|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Debug|x64.ActiveCfg = Debug|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Debug|x64.Build.0 = Debug|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Debug|x86.ActiveCfg = Debug|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Debug|x86.Build.0 = Debug|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|Any CPU.Build.0 = Release|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|x64.ActiveCfg = Release|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|x64.Build.0 = Release|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|x86.ActiveCfg = Release|Any CPU + {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -69,6 +150,8 @@ Global {FF832594-E403-2B1F-49E8-55A6A5C58268} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16} {15D2C271-E237-D10C-AFFC-2FB01BAF09C3} = {CE609670-85B9-0D16-FB54-ED063D5D8A6D} {F566C582-138F-7EAC-84B2-26BD8903E78D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {9154254D-286D-4BD9-A409-235E62E6AB99} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {646D0DF0-B048-4BFA-9672-6C5C78680E95} diff --git a/backend/Application/Application.csproj b/backend/Application/Application.csproj index b039920..74d5524 100644 --- a/backend/Application/Application.csproj +++ b/backend/Application/Application.csproj @@ -10,4 +10,7 @@ + + + diff --git a/backend/Application/Core/DependencyInjection.cs b/backend/Application/Core/DependencyInjection.cs index c9d7052..541c7d0 100644 --- a/backend/Application/Core/DependencyInjection.cs +++ b/backend/Application/Core/DependencyInjection.cs @@ -30,18 +30,21 @@ private static void AddUseCases(this IServiceCollection services) classes => classes.AssignableTo(typeof(IQueryHandler<,>)), publicOnly: false ) + .AsSelf() .AsImplementedInterfaces() .WithScopedLifetime() .AddClasses( classes => classes.AssignableTo(typeof(ICommandHandler<>)), publicOnly: false ) + .AsSelf() .AsImplementedInterfaces() .WithScopedLifetime() .AddClasses( classes => classes.AssignableTo(typeof(ICommandHandler<,>)), publicOnly: false ) + .AsSelf() .AsImplementedInterfaces() .WithScopedLifetime() ); diff --git a/backend/WebApi/Core/HttpFiles/Contacts.http b/backend/WebApi/Core/HttpFiles/Contacts.http index 239c3e3..516752c 100644 --- a/backend/WebApi/Core/HttpFiles/Contacts.http +++ b/backend/WebApi/Core/HttpFiles/Contacts.http @@ -16,9 +16,9 @@ POST {{HostAddress}}/api/v1/contacts Content-type: application/json { - "firstName": "John", + "firstName": "", "lastName": "Doe", - "email": "jdoe@gmail.com", + "email": "jdoe3@gmail.com", "phoneNumber": "091212121212" } diff --git a/tests/Domain.UnitTests/Assembly.cs b/tests/Domain.UnitTests/Assembly.cs new file mode 100644 index 0000000..5e80f0d --- /dev/null +++ b/tests/Domain.UnitTests/Assembly.cs @@ -0,0 +1,3 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: ExcludeFromCodeCoverage] diff --git a/tests/IntegrationTests/Contacts/CreateContactTests.cs b/tests/IntegrationTests/Contacts/CreateContactTests.cs new file mode 100644 index 0000000..704b248 --- /dev/null +++ b/tests/IntegrationTests/Contacts/CreateContactTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Net.Http.Json; +using Application.Contacts; +using Domain.Contacts; +using Domain.Core.Primitives; +using Infrastructure.Database; +using IntegrationTests.Core.Abstractions; +using IntegrationTests.Core.Extensions; +using TestData.Contacts.Create; +using WebApi.Contacts.Create; +using WebApi.Core.Constants; + +namespace IntegrationTests.Contacts; + +public class CreateContactTests(IntegrationTestWebAppFactory factory) + : BaseIntegrationTest(factory) +{ + private const string CreateContactRoute = $"api/v1/{Routes.Contacts.Create}"; + + [Theory] + [ClassData(typeof(CreateContactValidData))] + public async Task Should_ReturnCreated_WhenContactIsCreated( + CreateContactRequest request + ) + { + HttpResponseMessage response = await HttpClient.PostAsJsonAsync( + CreateContactRoute, + request + ); + + ContactResponse? result = + await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.NotNull(result); + + using PhoneForgeDbContext context = CreateDbContext(); + + Contact? contact = context.Contacts.FirstOrDefault(c => c.Id == result.Id); + Assert.NotNull(contact); + } + + [Theory] + [ClassData(typeof(CreateContactInvalidData))] + public async Task Should_ReturnBadRequest_WhenRequestIsInvalid( + CreateContactRequest request, + Error expected + ) + { + HttpResponseMessage response = await HttpClient.PostAsJsonAsync( + CreateContactRoute, + request + ); + + await response.AssertResponseErrorDetails(HttpStatusCode.BadRequest, expected); + } + + [Theory] + [ClassData(typeof(CreateContactValidData))] + public async Task Should_ReturnConflict_WhenEmailIsNotUnique( + CreateContactRequest request + ) + { + CreateContactRequest requestWithExistingEmail = request with + { + Email = DataSeeder.GetTestContact().Email, + }; + + Error expected = ContactErrors.EmailNotUnique; + + HttpResponseMessage response = await HttpClient.PostAsJsonAsync( + CreateContactRoute, + requestWithExistingEmail + ); + + await response.AssertResponseErrorDetails(HttpStatusCode.Conflict, expected); + } +} diff --git a/tests/IntegrationTests/Contacts/DeleteContactTests.cs b/tests/IntegrationTests/Contacts/DeleteContactTests.cs new file mode 100644 index 0000000..9e62d3a --- /dev/null +++ b/tests/IntegrationTests/Contacts/DeleteContactTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using Domain.Contacts; +using Domain.Core.Primitives; +using Infrastructure.Database; +using IntegrationTests.Core.Abstractions; +using IntegrationTests.Core.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace IntegrationTests.Contacts; + +public class DeleteContactTests(IntegrationTestWebAppFactory factory) + : BaseIntegrationTest(factory) +{ + [Fact] + public async Task Should_ReturnNoContent_WhenContactIsDeleted() + { + Guid contactId = DataSeeder.GetTestContact().Id; + + HttpResponseMessage response = await HttpClient.DeleteAsync( + $"api/v1/contacts/{contactId}" + ); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + using PhoneForgeDbContext context = CreateDbContext(); + + Contact? contact = await context + .Contacts.IgnoreQueryFilters() + .FirstOrDefaultAsync(c => c.Id == contactId); + + Assert.NotNull(contact); + Assert.Equal(contact.Id, contactId); + Assert.True(contact.Deleted); + Assert.NotNull(contact.DeletedOnUtc); + } + + [Fact] + public async Task Should_ReturnNotFound_WhenContactDoesNotExist() + { + Guid contactId = Guid.NewGuid(); + Error expected = ContactErrors.NotFoundById(contactId); + + HttpResponseMessage response = await HttpClient.DeleteAsync( + $"api/v1/contacts/{contactId}" + ); + + await response.AssertResponseErrorDetails(HttpStatusCode.NotFound, expected); + } +} diff --git a/tests/IntegrationTests/Contacts/GetContactByIdTests.cs b/tests/IntegrationTests/Contacts/GetContactByIdTests.cs new file mode 100644 index 0000000..3197f70 --- /dev/null +++ b/tests/IntegrationTests/Contacts/GetContactByIdTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Json; +using Application.Contacts; +using Domain.Contacts; +using Domain.Core.Primitives; +using IntegrationTests.Core.Abstractions; +using IntegrationTests.Core.Extensions; +using WebApi.Core.Constants; + +namespace IntegrationTests.Contacts; + +public class GetContactByIdTests(IntegrationTestWebAppFactory factory) + : BaseIntegrationTest(factory) +{ + private const string GetByIdRoute = $"api/v1/{Routes.Contacts.Get}"; + + [Fact] + public async Task Should_ReturnOk_And_Contact_WhenContactDoesExist() + { + Guid contactId = DataSeeder.GetTestContact().Id; + + ContactResponse? response = await HttpClient.GetFromJsonAsync( + $"{GetByIdRoute}/{contactId}" + ); + + Assert.NotNull(response); + } + + [Fact] + public async Task Should_ReturnNotFound_WhenContactDoesNotExist() + { + Guid contactId = Guid.NewGuid(); + Error expected = ContactErrors.NotFoundById(contactId); + + HttpResponseMessage response = await HttpClient.GetAsync( + $"{GetByIdRoute}/{contactId}" + ); + + await response.AssertResponseErrorDetails(HttpStatusCode.NotFound, expected); + } +} diff --git a/tests/IntegrationTests/Contacts/GetContactsTests.cs b/tests/IntegrationTests/Contacts/GetContactsTests.cs new file mode 100644 index 0000000..7aaee45 --- /dev/null +++ b/tests/IntegrationTests/Contacts/GetContactsTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Net.Http.Json; +using Application.Contacts.Get; +using Domain.Contacts; +using Domain.Core.Primitives; +using Infrastructure.Database; +using IntegrationTests.Core.Abstractions; +using IntegrationTests.Core.Extensions; +using TestData.Contacts.Get; +using WebApi.Contacts.Get; +using WebApi.Core.Constants; + +namespace IntegrationTests.Contacts; + +public class GetContactsTests(IntegrationTestWebAppFactory factory) + : BaseIntegrationTest(factory) +{ + private const string GetContactsRoute = $"api/v1/{Routes.Contacts.Get}"; + + [Theory] + [ClassData(typeof(GetContactsValidData))] + public async Task Should_ReturnCorrectPages( + GetContactsRequest request, + int totalCount, + int totalPages, + bool hasPreviousPage, + bool hasNextPage + ) + { + GetContactsResponse? response = + await HttpClient.GetFromJsonAsync( + $"{GetContactsRoute}?page={request.Page}" + ); + + Assert.NotNull(response); + Assert.Equal(totalCount, response.TotalCount); + Assert.Equal(totalPages, response.TotalPages); + Assert.Equal(request.Page, response.Page); + Assert.Equal(request.PageSize, response.PageSize); + Assert.Equal(hasPreviousPage, response.HasPreviousPage); + Assert.Equal(hasNextPage, response.HasNextPage); + + using PhoneForgeDbContext context = CreateDbContext(); + + List contacts = context + .Contacts.OrderBy(c => c.CreatedOnUtc) + .Select(c => c.Id) + .Take(10) + .ToList(); + + Assert.Equal(response.Items.Count, contacts.Count); + } + + [Fact] + public async Task Should_ReturnContacts_WithSearchTerm() + { + string email = DataSeeder.GetTestContact().Email; + + GetContactsResponse? response = + await HttpClient.GetFromJsonAsync( + $"{GetContactsRoute}?searchTerm={email}" + ); + + Assert.NotNull(response); + + using PhoneForgeDbContext context = CreateDbContext(); + + List contacts = context + .Contacts.Where(x => x.Email.Value.Contains(email)) + .ToList(); + + Assert.Equal(contacts.Count, response.Items.Count); + } + + [Theory] + [ClassData(typeof(GetContactsInvalidData))] + public async Task Should_ReturnError_WhenPagingDataIsInvalid( + GetContactsRequest request, + Error expected + ) + { + HttpResponseMessage? response = await HttpClient.GetAsync( + $"{GetContactsRoute}?page={request.Page}&pageSize={request.PageSize}" + ); + + await response.AssertResponseErrorDetails(HttpStatusCode.BadRequest, expected); + } +} diff --git a/tests/IntegrationTests/Contacts/UpdateContactTests.cs b/tests/IntegrationTests/Contacts/UpdateContactTests.cs new file mode 100644 index 0000000..3bdb0a7 --- /dev/null +++ b/tests/IntegrationTests/Contacts/UpdateContactTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Http.Json; +using Domain.Contacts; +using Domain.Core.Primitives; +using Infrastructure.Database; +using IntegrationTests.Core.Abstractions; +using IntegrationTests.Core.Extensions; +using TestData.Contacts.Update; +using WebApi.Contacts.Update; + +namespace IntegrationTests.Contacts; + +public class UpdateContactTests(IntegrationTestWebAppFactory factory) + : BaseIntegrationTest(factory) +{ + [Theory] + [ClassData(typeof(UpdateContactValidData))] + public async Task Should_ReturnNoContent_WhenContactWasUpdated( + UpdateContactRequest request + ) + { + Guid contactId = DataSeeder.GetTestContact().Id; + + HttpResponseMessage response = await HttpClient.PutAsJsonAsync( + $"api/v1/contacts/{contactId}", + request + ); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + using PhoneForgeDbContext context = CreateDbContext(); + + Contact? contact = context.Contacts.FirstOrDefault(c => c.Id == contactId); + + Assert.NotNull(contact); + Assert.Equal(request.FirstName, contact.FirstName); + Assert.Equal(request.LastName, contact.LastName); + Assert.Equal(request.Email, contact.Email); + Assert.Equal(request.PhoneNumber, contact.PhoneNumber); + Assert.NotNull(contact.ModifiedOnUtc); + } + + [Theory] + [ClassData(typeof(UpdateContactWithNonExistingIdData))] + public async Task Should_ReturnNotFound_WhenContactDoesNotExist( + UpdateContactRequest request, + Guid contactId, + Error expected + ) + { + HttpResponseMessage response = await HttpClient.PutAsJsonAsync( + $"api/v1/contacts/{contactId}", + request + ); + + await response.AssertResponseErrorDetails(HttpStatusCode.NotFound, expected); + } + + [Theory] + [ClassData(typeof(UpdateContactInvalidData))] + public async Task Should_ReturnBadRequest_WhenRequestIsInvalid( + UpdateContactRequest request, + Error expected + ) + { + Guid contactId = DataSeeder.GetTestContact().Id; + + HttpResponseMessage response = await HttpClient.PutAsJsonAsync( + $"api/v1/contacts/{contactId}", + request + ); + + await response.AssertResponseErrorDetails(HttpStatusCode.BadRequest, expected); + } + + [Theory] + [ClassData(typeof(UpdateContactValidData))] + public async Task Should_ReturnConflict_WhenEmailIsNotUnique( + UpdateContactRequest request + ) + { + Error expected = ContactErrors.EmailNotUnique; + Contact contact = DataSeeder.GetTestContact(); + UpdateContactRequest requestWithExistingEmail = request with + { + Email = contact.Email, + }; + + HttpResponseMessage response = await HttpClient.PutAsJsonAsync( + $"api/v1/contacts/{contact.Id}", + requestWithExistingEmail + ); + + await response.AssertResponseErrorDetails(HttpStatusCode.Conflict, expected); + } +} diff --git a/tests/IntegrationTests/Core/Abstractions/BaseIntegrationTest.cs b/tests/IntegrationTests/Core/Abstractions/BaseIntegrationTest.cs new file mode 100644 index 0000000..198e6a2 --- /dev/null +++ b/tests/IntegrationTests/Core/Abstractions/BaseIntegrationTest.cs @@ -0,0 +1,45 @@ +using Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestData.Seeding; + +namespace IntegrationTests.Core.Abstractions; + +[Collection("IntegrationTests")] +public abstract class BaseIntegrationTest : IAsyncLifetime +{ + private readonly IntegrationTestWebAppFactory _factory; + private readonly PhoneForgeDbContext _context; + protected readonly TestDataSeeder DataSeeder; + protected readonly HttpClient HttpClient; + + protected BaseIntegrationTest(IntegrationTestWebAppFactory factory) + { + _factory = factory; + _context = _factory + .Services.CreateScope() + .ServiceProvider.GetRequiredService(); + + DataSeeder = new TestDataSeeder(_context); + HttpClient = factory.CreateClient(); + } + + public async Task InitializeAsync() + { + await DataSeeder.SeedAsync(); + } + + public async Task DisposeAsync() + { + await _context.Database.ExecuteSqlRawAsync("DELETE FROM Contacts"); + } + + public PhoneForgeDbContext CreateDbContext() + { + PhoneForgeDbContext context = _factory + .Services.CreateScope() + .ServiceProvider.GetRequiredService(); + + return context; + } +} diff --git a/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs b/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs new file mode 100644 index 0000000..80fe53b --- /dev/null +++ b/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using Infrastructure.Database; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +[assembly: ExcludeFromCodeCoverage] + +namespace IntegrationTests.Core.Abstractions; + +public class IntegrationTestWebAppFactory : WebApplicationFactory +{ + public IConfiguration? Configuration { get; private set; } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration(config => + { + Configuration = new ConfigurationBuilder() + .AddJsonFile("integrationsettings.json") + .Build(); + + config.AddConfiguration(Configuration); + }); + + builder.ConfigureTestServices(services => + { + services.RemoveAll>(); + + string? connectionString = Configuration?.GetConnectionString( + "PhoneForgeTests" + ); + + services.AddSqlServer(connectionString); + }); + } +} diff --git a/tests/IntegrationTests/Core/Abstractions/SharedTestCollection.cs b/tests/IntegrationTests/Core/Abstractions/SharedTestCollection.cs new file mode 100644 index 0000000..c228d5a --- /dev/null +++ b/tests/IntegrationTests/Core/Abstractions/SharedTestCollection.cs @@ -0,0 +1,4 @@ +namespace IntegrationTests.Core.Abstractions; + +[CollectionDefinition("IntegrationTests")] +public class SharedTestCollection : ICollectionFixture { } diff --git a/tests/IntegrationTests/Core/Contracts/CustomProblemDetails.cs b/tests/IntegrationTests/Core/Contracts/CustomProblemDetails.cs new file mode 100644 index 0000000..1c9e384 --- /dev/null +++ b/tests/IntegrationTests/Core/Contracts/CustomProblemDetails.cs @@ -0,0 +1,10 @@ +namespace IntegrationTests.Core.Contracts; + +internal sealed class CustomProblemDetails +{ + public string? Type { get; set; } + public string? Title { get; set; } + public int Status { get; set; } + public string? Detail { get; set; } + public List Errors { get; set; } = []; +} diff --git a/tests/IntegrationTests/Core/Extensions/HttpResponseMessageExtensions.cs b/tests/IntegrationTests/Core/Extensions/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..dcb7731 --- /dev/null +++ b/tests/IntegrationTests/Core/Extensions/HttpResponseMessageExtensions.cs @@ -0,0 +1,30 @@ +using System.Net; +using System.Net.Http.Json; +using Domain.Core.Primitives; +using IntegrationTests.Core.Contracts; + +namespace IntegrationTests.Core.Extensions; + +internal static class HttpResponseMessageExtensions +{ + internal static async Task AssertResponseErrorDetails( + this HttpResponseMessage response, + HttpStatusCode statusCode, + Error expected + ) + { + if (response.IsSuccessStatusCode) + { + throw new InvalidOperationException("Successful response."); + } + + CustomProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Null problem details."); + + Assert.Equal(statusCode, response.StatusCode); + Assert.NotNull(problemDetails); + Assert.Equal(expected.Description, problemDetails.Errors[0]); + Assert.Equal(expected.Code, problemDetails.Title); + } +} diff --git a/tests/IntegrationTests/IntegrationTests.csproj b/tests/IntegrationTests/IntegrationTests.csproj new file mode 100644 index 0000000..b23e840 --- /dev/null +++ b/tests/IntegrationTests/IntegrationTests.csproj @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/IntegrationTests/integrationsettings.json b/tests/IntegrationTests/integrationsettings.json new file mode 100644 index 0000000..ddefced --- /dev/null +++ b/tests/IntegrationTests/integrationsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings":{ + "PhoneForgeTests": "Data Source=(localdb)\\MSSQLLocalDB; Initial Catalog=PhoneForgeDb_Tests; Integrated Security=SSPI" + } +} \ No newline at end of file diff --git a/tests/TestData/Assembly.cs b/tests/TestData/Assembly.cs new file mode 100644 index 0000000..5e80f0d --- /dev/null +++ b/tests/TestData/Assembly.cs @@ -0,0 +1,3 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: ExcludeFromCodeCoverage] diff --git a/tests/TestData/Contacts/ContactData.cs b/tests/TestData/Contacts/ContactData.cs new file mode 100644 index 0000000..799fce7 --- /dev/null +++ b/tests/TestData/Contacts/ContactData.cs @@ -0,0 +1,14 @@ +using Domain.Contacts; + +namespace TestData.Contacts; + +public static class ContactData +{ + public static Contact ValidContact => + Contact.Create( + FirstNameData.ValidFirstName, + LastNameData.ValidLastName, + EmailData.ValidEmail, + PhoneNumberData.ValidPhoneNumber + ); +} diff --git a/tests/TestData/Contacts/Create/CreateContactInvalidData.cs b/tests/TestData/Contacts/Create/CreateContactInvalidData.cs new file mode 100644 index 0000000..accb789 --- /dev/null +++ b/tests/TestData/Contacts/Create/CreateContactInvalidData.cs @@ -0,0 +1,32 @@ +using Domain.Contacts; +using Domain.Core.Primitives; +using WebApi.Contacts.Create; +using Xunit; + +namespace TestData.Contacts.Create; + +public class CreateContactInvalidData : TheoryData +{ + public CreateContactInvalidData() + { + Add( + CreateContactRequestData.CreateRequestWithInvalidFirstName(), + ContactErrors.FirstName.LongerThanAllowed + ); + + Add( + CreateContactRequestData.CreateRequestWithInvalidLastName(), + ContactErrors.LastName.LongerThanAllowed + ); + + Add( + CreateContactRequestData.CreateRequestWithInvalidEmail(), + ContactErrors.Email.LongerThanAllowed + ); + + Add( + CreateContactRequestData.CreateRequestWithInvalidPhoneNumber(), + ContactErrors.PhoneNumber.LongerThanAllowed + ); + } +} diff --git a/tests/TestData/Contacts/Create/CreateContactRequestData.cs b/tests/TestData/Contacts/Create/CreateContactRequestData.cs new file mode 100644 index 0000000..c965b9f --- /dev/null +++ b/tests/TestData/Contacts/Create/CreateContactRequestData.cs @@ -0,0 +1,56 @@ +using WebApi.Contacts.Create; + +namespace TestData.Contacts.Create; + +public static class CreateContactRequestData +{ + public static CreateContactRequest CreateValidRequest() + { + return new CreateContactRequest( + FirstNameData.ValidFirstName, + LastNameData.ValidLastName, + EmailData.ValidEmail, + PhoneNumberData.ValidPhoneNumber + ); + } + + public static CreateContactRequest CreateRequestWithInvalidFirstName() + { + return new CreateContactRequest( + FirstNameData.LongerThanAllowedFirstName, + LastNameData.ValidLastName, + EmailData.ValidEmail, + PhoneNumberData.ValidPhoneNumber + ); + } + + public static CreateContactRequest CreateRequestWithInvalidLastName() + { + return new CreateContactRequest( + FirstNameData.ValidFirstName, + LastNameData.LongerThanAllowedLastName, + EmailData.ValidEmail, + PhoneNumberData.ValidPhoneNumber + ); + } + + public static CreateContactRequest CreateRequestWithInvalidEmail() + { + return new CreateContactRequest( + FirstNameData.ValidFirstName, + LastNameData.ValidLastName, + EmailData.LongerThanAllowedEmail, + PhoneNumberData.ValidPhoneNumber + ); + } + + public static CreateContactRequest CreateRequestWithInvalidPhoneNumber() + { + return new CreateContactRequest( + FirstNameData.ValidFirstName, + LastNameData.ValidLastName, + EmailData.ValidEmail, + PhoneNumberData.LongerThanAllowedPhoneNumber + ); + } +} diff --git a/tests/TestData/Contacts/Create/CreateContactValidData.cs b/tests/TestData/Contacts/Create/CreateContactValidData.cs new file mode 100644 index 0000000..7f7aa36 --- /dev/null +++ b/tests/TestData/Contacts/Create/CreateContactValidData.cs @@ -0,0 +1,12 @@ +using WebApi.Contacts.Create; +using Xunit; + +namespace TestData.Contacts.Create; + +public class CreateContactValidData : TheoryData +{ + public CreateContactValidData() + { + Add(CreateContactRequestData.CreateValidRequest()); + } +} diff --git a/tests/TestData/Contacts/EmailData.cs b/tests/TestData/Contacts/EmailData.cs new file mode 100644 index 0000000..9c86bd4 --- /dev/null +++ b/tests/TestData/Contacts/EmailData.cs @@ -0,0 +1,12 @@ +using Domain.Contacts; + +namespace TestData.Contacts; + +public static class EmailData +{ + public static readonly Email ValidEmail = Email.Create("test@phoneforge.com").Value; + + public static readonly string LongerThanAllowedEmail = new('*', Email.MaxLength + 1); + + public static readonly string InvalidFormatEmail = new("invalid-format-email"); +} diff --git a/tests/TestData/Contacts/FirstNameData.cs b/tests/TestData/Contacts/FirstNameData.cs new file mode 100644 index 0000000..6a080cc --- /dev/null +++ b/tests/TestData/Contacts/FirstNameData.cs @@ -0,0 +1,15 @@ +using Domain.Contacts; + +namespace TestData.Contacts; + +public static class FirstNameData +{ + public static readonly FirstName ValidFirstName = FirstName + .Create(nameof(FirstName)) + .Value; + + public static readonly string LongerThanAllowedFirstName = new( + '*', + FirstName.MaxLength + 1 + ); +} diff --git a/tests/TestData/Contacts/Get/GetContactsInvalidData.cs b/tests/TestData/Contacts/Get/GetContactsInvalidData.cs new file mode 100644 index 0000000..a1ad173 --- /dev/null +++ b/tests/TestData/Contacts/Get/GetContactsInvalidData.cs @@ -0,0 +1,25 @@ +using Domain.Core.Pagination; +using Domain.Core.Primitives; +using WebApi.Contacts.Get; +using Xunit; + +namespace TestData.Contacts.Get; + +public class GetContactsInvalidData : TheoryData +{ + public GetContactsInvalidData() + { + Add( + GetContactsRequestData.CreateRequestWithInvalidPage(), + PaginationErrors.InvalidPage + ); + Add( + GetContactsRequestData.CreateRequestWithGreaterThanAllowedPageSize(), + PaginationErrors.GreaterThanAllowed + ); + Add( + GetContactsRequestData.CreateRequestWithLowerThanAllowedPageSize(), + PaginationErrors.LowerThanAllowed + ); + } +} diff --git a/tests/TestData/Contacts/Get/GetContactsRequestData.cs b/tests/TestData/Contacts/Get/GetContactsRequestData.cs new file mode 100644 index 0000000..7e6848b --- /dev/null +++ b/tests/TestData/Contacts/Get/GetContactsRequestData.cs @@ -0,0 +1,32 @@ +using Domain.Core.Pagination; +using WebApi.Contacts.Get; + +namespace TestData.Contacts.Get; + +public static class GetContactsRequestData +{ + public static GetContactsRequest CreateDefaultValidRequest() + { + return new GetContactsRequest(null); + } + + public static GetContactsRequest CreateValidRequestWithSecondPage() + { + return new GetContactsRequest(null, 2); + } + + public static GetContactsRequest CreateRequestWithInvalidPage() + { + return new GetContactsRequest(null, Page: 0); + } + + public static GetContactsRequest CreateRequestWithGreaterThanAllowedPageSize() + { + return new GetContactsRequest(null, PageSize: PageSize.MaximumPageSize + 1); + } + + public static GetContactsRequest CreateRequestWithLowerThanAllowedPageSize() + { + return new GetContactsRequest(null, PageSize: PageSize.MinimumPageSize - 1); + } +} diff --git a/tests/TestData/Contacts/Get/GetContactsValidData.cs b/tests/TestData/Contacts/Get/GetContactsValidData.cs new file mode 100644 index 0000000..bef264d --- /dev/null +++ b/tests/TestData/Contacts/Get/GetContactsValidData.cs @@ -0,0 +1,19 @@ +using WebApi.Contacts.Get; +using Xunit; + +namespace TestData.Contacts.Get; + +public class GetContactsValidData : TheoryData +{ + public GetContactsValidData() + { + Add(GetContactsRequestData.CreateDefaultValidRequest(), 20, 2, false, true); + Add( + GetContactsRequestData.CreateValidRequestWithSecondPage(), + 20, + 2, + true, + false + ); + } +} diff --git a/tests/TestData/Contacts/LastNameData.cs b/tests/TestData/Contacts/LastNameData.cs new file mode 100644 index 0000000..37cf7bd --- /dev/null +++ b/tests/TestData/Contacts/LastNameData.cs @@ -0,0 +1,15 @@ +using Domain.Contacts; + +namespace TestData.Contacts; + +public static class LastNameData +{ + public static readonly LastName ValidLastName = LastName + .Create(nameof(LastName)) + .Value; + + public static readonly string LongerThanAllowedLastName = new( + '*', + LastName.MaxLength + 1 + ); +} diff --git a/tests/TestData/Contacts/PhoneNumberData.cs b/tests/TestData/Contacts/PhoneNumberData.cs new file mode 100644 index 0000000..9eec34f --- /dev/null +++ b/tests/TestData/Contacts/PhoneNumberData.cs @@ -0,0 +1,20 @@ +using Domain.Contacts; + +namespace TestData.Contacts; + +public static class PhoneNumberData +{ + public static readonly PhoneNumber ValidPhoneNumber = PhoneNumber + .Create("0919876543") + .Value; + + public static readonly string LongerThanAllowedPhoneNumber = new( + '*', + PhoneNumber.MaxLength + 1 + ); + + public static readonly string ShorterThanAllowedPhoneNumber = new( + '*', + PhoneNumber.MinLength - 1 + ); +} diff --git a/tests/TestData/Contacts/Update/UpdateContactInvalidData.cs b/tests/TestData/Contacts/Update/UpdateContactInvalidData.cs new file mode 100644 index 0000000..c27b8fa --- /dev/null +++ b/tests/TestData/Contacts/Update/UpdateContactInvalidData.cs @@ -0,0 +1,32 @@ +using Domain.Contacts; +using Domain.Core.Primitives; +using WebApi.Contacts.Update; +using Xunit; + +namespace TestData.Contacts.Update; + +public class UpdateContactInvalidData : TheoryData +{ + public UpdateContactInvalidData() + { + Add( + UpdateContactRequestData.CreateRequestWithInvalidFirstName(), + ContactErrors.FirstName.LongerThanAllowed + ); + + Add( + UpdateContactRequestData.CreateRequestWithInvalidLastName(), + ContactErrors.LastName.LongerThanAllowed + ); + + Add( + UpdateContactRequestData.CreateRequestWithInvalidEmail(), + ContactErrors.Email.LongerThanAllowed + ); + + Add( + UpdateContactRequestData.CreateRequestWithInvalidPhoneNumber(), + ContactErrors.PhoneNumber.LongerThanAllowed + ); + } +} diff --git a/tests/TestData/Contacts/Update/UpdateContactRequestData.cs b/tests/TestData/Contacts/Update/UpdateContactRequestData.cs new file mode 100644 index 0000000..cf00b0a --- /dev/null +++ b/tests/TestData/Contacts/Update/UpdateContactRequestData.cs @@ -0,0 +1,56 @@ +using WebApi.Contacts.Update; + +namespace TestData.Contacts.Update; + +public static class UpdateContactRequestData +{ + public static UpdateContactRequest CreateValidRequest() + { + return new UpdateContactRequest( + FirstNameData.ValidFirstName, + LastNameData.ValidLastName, + EmailData.ValidEmail, + PhoneNumberData.ValidPhoneNumber + ); + } + + public static UpdateContactRequest CreateRequestWithInvalidFirstName() + { + return new UpdateContactRequest( + FirstNameData.LongerThanAllowedFirstName, + LastNameData.ValidLastName, + EmailData.ValidEmail, + PhoneNumberData.ValidPhoneNumber + ); + } + + public static UpdateContactRequest CreateRequestWithInvalidLastName() + { + return new UpdateContactRequest( + FirstNameData.ValidFirstName, + LastNameData.LongerThanAllowedLastName, + EmailData.ValidEmail, + PhoneNumberData.ValidPhoneNumber + ); + } + + public static UpdateContactRequest CreateRequestWithInvalidEmail() + { + return new UpdateContactRequest( + FirstNameData.ValidFirstName, + LastNameData.ValidLastName, + EmailData.LongerThanAllowedEmail, + PhoneNumberData.ValidPhoneNumber + ); + } + + public static UpdateContactRequest CreateRequestWithInvalidPhoneNumber() + { + return new UpdateContactRequest( + FirstNameData.ValidFirstName, + LastNameData.ValidLastName, + EmailData.ValidEmail, + PhoneNumberData.LongerThanAllowedPhoneNumber + ); + } +} diff --git a/tests/TestData/Contacts/Update/UpdateContactValidData.cs b/tests/TestData/Contacts/Update/UpdateContactValidData.cs new file mode 100644 index 0000000..3824f5a --- /dev/null +++ b/tests/TestData/Contacts/Update/UpdateContactValidData.cs @@ -0,0 +1,12 @@ +using WebApi.Contacts.Update; +using Xunit; + +namespace TestData.Contacts.Update; + +public class UpdateContactValidData : TheoryData +{ + public UpdateContactValidData() + { + Add(UpdateContactRequestData.CreateValidRequest()); + } +} diff --git a/tests/TestData/Contacts/Update/UpdateContactWithNonExistingIdData.cs b/tests/TestData/Contacts/Update/UpdateContactWithNonExistingIdData.cs new file mode 100644 index 0000000..dd22cd8 --- /dev/null +++ b/tests/TestData/Contacts/Update/UpdateContactWithNonExistingIdData.cs @@ -0,0 +1,21 @@ +using Domain.Contacts; +using Domain.Core.Primitives; +using WebApi.Contacts.Update; +using Xunit; + +namespace TestData.Contacts.Update; + +public class UpdateContactWithNonExistingIdData + : TheoryData +{ + private readonly Guid _id = Guid.NewGuid(); + + public UpdateContactWithNonExistingIdData() + { + Add( + UpdateContactRequestData.CreateValidRequest(), + _id, + ContactErrors.NotFoundById(_id) + ); + } +} diff --git a/tests/TestData/Pagination/PageData.cs b/tests/TestData/Pagination/PageData.cs new file mode 100644 index 0000000..ade228b --- /dev/null +++ b/tests/TestData/Pagination/PageData.cs @@ -0,0 +1,10 @@ +using Domain.Core.Pagination; + +namespace TestData.Pagination; + +public static class PageData +{ + public static readonly Page ValidPage = Page.Create(1).Value; + + public static readonly int InvalidPage = -1; +} diff --git a/tests/TestData/Pagination/PageSizeData.cs b/tests/TestData/Pagination/PageSizeData.cs new file mode 100644 index 0000000..ba1f7da --- /dev/null +++ b/tests/TestData/Pagination/PageSizeData.cs @@ -0,0 +1,13 @@ +using Domain.Core.Pagination; + +namespace TestData.Pagination; + +public static class PageSizeData +{ + public static readonly PageSize ValidPageSize = PageSize + .Create(PageSize.MinimumPageSize) + .Value; + + public static readonly int GreaterThenAllowedPageSize = PageSize.MaximumPageSize + 1; + public static readonly int LowerThenAllowedPageSize = PageSize.MinimumPageSize - 1; +} diff --git a/tests/TestData/Seeding/TestDataSeeder.cs b/tests/TestData/Seeding/TestDataSeeder.cs new file mode 100644 index 0000000..4d71523 --- /dev/null +++ b/tests/TestData/Seeding/TestDataSeeder.cs @@ -0,0 +1,35 @@ +using Bogus; +using Domain.Contacts; +using Infrastructure.Database; + +namespace TestData.Seeding; + +public class TestDataSeeder(PhoneForgeDbContext context) +{ + private readonly PhoneForgeDbContext _context = context; + private readonly List _contacts = []; + + public async Task SeedAsync() + { + await SeedContacts(); + + await _context.SaveChangesAsync(); + } + + public async Task SeedContacts() + { + Faker contactFaker = new Faker().CustomInstantiator(f => + Contact.Create( + FirstName.Create(f.Name.FirstName()).Value, + LastName.Create(f.Name.LastName()).Value, + Email.Create(f.Internet.Email()).Value, + PhoneNumber.Create($"09{f.Random.Int(100000, 9999999)}").Value + ) + ); + + _contacts.AddRange(contactFaker.Generate(20)); + await _context.Contacts.AddRangeAsync(_contacts); + } + + public Contact GetTestContact(int index = 0) => _contacts[index]; +} diff --git a/tests/TestData/TestData.csproj b/tests/TestData/TestData.csproj new file mode 100644 index 0000000..5bef45d --- /dev/null +++ b/tests/TestData/TestData.csproj @@ -0,0 +1,9 @@ + + + + + + + + + From 481da8c205cbdfc7717dfc87919eef662d82d233 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:50:20 +0100 Subject: [PATCH 26/46] test: use TestData in unit tests (#45) * rename unit tests project to UnitTests * use test data in unit tests --- Directory.Packages.props | 58 +++++------ PhoneForge.sln | 2 +- .../Domain.UnitTests/Contacts/ContactTests.cs | 63 ++++++------ tests/Domain.UnitTests/Contacts/EmailTests.cs | 44 ++------- .../Contacts/FirstNameTests.cs | 32 ++---- .../Contacts/LastNameTests.cs | 32 ++---- .../Contacts/PhoneNumberTests.cs | 56 ++--------- .../Pagination/PageSizeTests.cs | 33 ++----- .../Domain.UnitTests/Pagination/PageTests.cs | 2 +- .../Pagination/PagedListTests.cs | 99 +++++-------------- ...main.UnitTests.csproj => UnitTests.csproj} | 1 + tests/TestData/Contacts/ContactData.cs | 4 + tests/TestData/Contacts/ContactUpdate.cs | 17 ++++ tests/TestData/Contacts/ContactValid.cs | 22 +++++ .../Create/CreateContactRequestData.cs | 4 + .../Update/UpdateContactRequestData.cs | 4 + tests/TestData/Emails/Cases/EmailInvalid.cs | 17 ++++ tests/TestData/Emails/Cases/EmailValid.cs | 11 +++ .../{Contacts => Emails}/EmailData.cs | 2 +- .../FirstNames/Cases/FirstNameInvalid.cs | 19 ++++ .../FirstNames/Cases/FirstNameValid.cs | 11 +++ .../{Contacts => FirstNames}/FirstNameData.cs | 2 +- .../LastNames/Cases/LastNameInvalid.cs | 19 ++++ .../TestData/LastNames/Cases/LastNameValid.cs | 11 +++ .../{Contacts => LastNames}/LastNameData.cs | 2 +- .../Pagination/Cases/PageSizeInvalid.cs | 14 +++ .../Pagination/Cases/PageSizeValid.cs | 11 +++ .../Pagination/Cases/PagedListValid.cs | 30 ++++++ tests/TestData/Pagination/PageData.cs | 3 +- tests/TestData/Pagination/PageSizeData.cs | 2 +- tests/TestData/Pagination/PagedListData.cs | 6 ++ .../PhoneNumbers/Cases/PhoneNumberInvalid.cs | 27 +++++ .../PhoneNumbers/Cases/PhoneNumberValid.cs | 11 +++ .../PhoneNumberData.cs | 4 +- tests/TestData/TestData.csproj | 4 + 35 files changed, 377 insertions(+), 302 deletions(-) rename tests/Domain.UnitTests/{Domain.UnitTests.csproj => UnitTests.csproj} (87%) create mode 100644 tests/TestData/Contacts/ContactUpdate.cs create mode 100644 tests/TestData/Contacts/ContactValid.cs create mode 100644 tests/TestData/Emails/Cases/EmailInvalid.cs create mode 100644 tests/TestData/Emails/Cases/EmailValid.cs rename tests/TestData/{Contacts => Emails}/EmailData.cs (91%) create mode 100644 tests/TestData/FirstNames/Cases/FirstNameInvalid.cs create mode 100644 tests/TestData/FirstNames/Cases/FirstNameValid.cs rename tests/TestData/{Contacts => FirstNames}/FirstNameData.cs (90%) create mode 100644 tests/TestData/LastNames/Cases/LastNameInvalid.cs create mode 100644 tests/TestData/LastNames/Cases/LastNameValid.cs rename tests/TestData/{Contacts => LastNames}/LastNameData.cs (90%) create mode 100644 tests/TestData/Pagination/Cases/PageSizeInvalid.cs create mode 100644 tests/TestData/Pagination/Cases/PageSizeValid.cs create mode 100644 tests/TestData/Pagination/Cases/PagedListValid.cs create mode 100644 tests/TestData/Pagination/PagedListData.cs create mode 100644 tests/TestData/PhoneNumbers/Cases/PhoneNumberInvalid.cs create mode 100644 tests/TestData/PhoneNumbers/Cases/PhoneNumberValid.cs rename tests/TestData/{Contacts => PhoneNumbers}/PhoneNumberData.cs (78%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 92d7aca..e1b4b86 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,30 +1,30 @@ - - true - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - \ No newline at end of file + + true + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/PhoneForge.sln b/PhoneForge.sln index 6acbfb1..26f21bc 100644 --- a/PhoneForge.sln +++ b/PhoneForge.sln @@ -19,7 +19,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi", "backend\WebApi\We EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Console", "frontend\Console\Console.csproj", "{15D2C271-E237-D10C-AFFC-2FB01BAF09C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain.UnitTests", "tests\Domain.UnitTests\Domain.UnitTests.csproj", "{F566C582-138F-7EAC-84B2-26BD8903E78D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\Domain.UnitTests\UnitTests.csproj", "{F566C582-138F-7EAC-84B2-26BD8903E78D}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{089100B1-113F-4E66-888A-E83F3999EAFD}" ProjectSection(SolutionItems) = preProject diff --git a/tests/Domain.UnitTests/Contacts/ContactTests.cs b/tests/Domain.UnitTests/Contacts/ContactTests.cs index 188f8bb..81d0fdd 100644 --- a/tests/Domain.UnitTests/Contacts/ContactTests.cs +++ b/tests/Domain.UnitTests/Contacts/ContactTests.cs @@ -1,48 +1,47 @@ using Domain.Contacts; +using TestData.Contacts; -namespace Domain.UnitTests.Contacts; +namespace UnitTests.Contacts; public class ContactTests { - private static readonly FirstName _firstName = FirstName.Create("John").Value; - private static readonly LastName _lastName = LastName.Create("Doe").Value; - private static readonly Email _email = Email.Create("jdoe@gmail.com").Value; - private static readonly PhoneNumber _phoneNumber = PhoneNumber - .Create("0919876543") - .Value; - private static string _fullName => $"{_firstName.Value} {_lastName.Value}"; - - [Fact] - public void Create_Should_ReturnContact_WithValidData() + [Theory] + [ClassData(typeof(ContactValid))] + public void Create_Should_ReturnContact_WithValidData( + FirstName firstName, + LastName lastName, + Email email, + PhoneNumber phoneNumber, + string fullName + ) { - Contact contact = Contact.Create(_firstName, _lastName, _email, _phoneNumber); + Contact contact = Contact.Create(firstName, lastName, email, phoneNumber); Assert.NotNull(contact); Assert.NotEqual(Guid.Empty, contact.Id); - Assert.Equal(_firstName, contact.FirstName); - Assert.Equal(_lastName, contact.LastName); - Assert.Equal(_email, contact.Email); - Assert.Equal(_phoneNumber, contact.PhoneNumber); - Assert.Equal(_fullName, contact.FullName); + Assert.Equal(firstName, contact.FirstName); + Assert.Equal(lastName, contact.LastName); + Assert.Equal(email, contact.Email); + Assert.Equal(phoneNumber, contact.PhoneNumber); + Assert.Equal(fullName, contact.FullName); } - [Fact] - public void Update_Should_ChangeAllProperties() + [Theory] + [ClassData(typeof(ContactUpdate))] + public void Update_Should_ChangeAllProperties( + FirstName firstName, + LastName lastName, + Email email, + PhoneNumber phoneNumber + ) { - Contact contact = Contact.Create(_firstName, _lastName, _email, _phoneNumber); - - FirstName newFirstName = FirstName.Create("Ada").Value; - LastName newLastName = LastName.Create("Lovelace").Value; - Email newEmail = Email.Create("alovelace@gmail.com").Value; - PhoneNumber newPhoneNumber = PhoneNumber.Create("0913456789").Value; - string newFullName = $"{newFirstName.Value} {newLastName.Value}"; + Contact contact = ContactData.ValidContact; - contact.UpdateContact(newFirstName, newLastName, newEmail, newPhoneNumber); + contact.UpdateContact(firstName, lastName, email, phoneNumber); - Assert.Equal(newFirstName, contact.FirstName); - Assert.Equal(newLastName, contact.LastName); - Assert.Equal(newEmail, contact.Email); - Assert.Equal(newPhoneNumber, contact.PhoneNumber); - Assert.Equal(newFullName, contact.FullName); + Assert.Equal(firstName, contact.FirstName); + Assert.Equal(lastName, contact.LastName); + Assert.Equal(email, contact.Email); + Assert.Equal(phoneNumber, contact.PhoneNumber); } } diff --git a/tests/Domain.UnitTests/Contacts/EmailTests.cs b/tests/Domain.UnitTests/Contacts/EmailTests.cs index 8419326..2c0725f 100644 --- a/tests/Domain.UnitTests/Contacts/EmailTests.cs +++ b/tests/Domain.UnitTests/Contacts/EmailTests.cs @@ -1,56 +1,28 @@ using Domain.Contacts; using Domain.Core.Primitives; +using TestData.Emails.Cases; -namespace Domain.UnitTests.Contacts; +namespace UnitTests.Contacts; public class EmailTests { - [Fact] - public void Create_Should_ReturnSuccess_WithValidInput() + [Theory] + [ClassData(typeof(EmailValid))] + public void Create_Should_ReturnSuccess_WithValidInput(string input) { - string input = "jdoe@gmail.com"; - Result result = Email.Create(input); Assert.True(result.IsSuccess); - Assert.False(result.IsFailure); Assert.Equal(input, result.Value); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Create_Should_ReturnError_WithNullOrEmptyInput(string? input) - { - Result result = Email.Create(input); - - Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.Email.NullOrEmpty, result.Error); - } - - [Fact] - public void Create_Should_ReturnError_WithTooLongInput() - { - string input = new('a', Email.MaxLength + 1); - - Result result = Email.Create(input); - - Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.Email.LongerThanAllowed, result.Error); - } - - [Theory] - [InlineData("jdoe")] - [InlineData("jdoegmail.com")] - public void Create_Should_ReturnError_WithInvalidFormatInput(string? input) + [ClassData(typeof(EmailInvalid))] + public void Create_Should_ReturnError_WithInvalidInput(string? input, Error expected) { Result result = Email.Create(input); Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.Email.InvalidFormat, result.Error); + Assert.Equal(expected, result.Error); } } diff --git a/tests/Domain.UnitTests/Contacts/FirstNameTests.cs b/tests/Domain.UnitTests/Contacts/FirstNameTests.cs index 5903024..bd2a081 100644 --- a/tests/Domain.UnitTests/Contacts/FirstNameTests.cs +++ b/tests/Domain.UnitTests/Contacts/FirstNameTests.cs @@ -1,44 +1,28 @@ using Domain.Contacts; using Domain.Core.Primitives; +using TestData.FirstNames.Cases; -namespace Domain.UnitTests.Contacts; +namespace UnitTests.Contacts; public class FirstNameTests { - [Fact] - public void Create_Should_ReturnSuccess_WithValidInput() + [Theory] + [ClassData(typeof(FirstNameValid))] + public void Create_Should_ReturnSuccess_WithValidInput(string input) { - string input = "John"; - Result result = FirstName.Create(input); Assert.True(result.IsSuccess); - Assert.False(result.IsFailure); Assert.Equal(input, result.Value); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Create_Should_ReturnError_WithNullOrEmptyInput(string? input) + [ClassData(typeof(FirstNameInvalid))] + public void Create_Should_ReturnError_WithInvalidInput(string? input, Error expected) { Result result = FirstName.Create(input); Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.FirstName.NullOrEmpty, result.Error); - } - - [Fact] - public void Create_Should_ReturnError_WithTooLongInput() - { - string input = new('a', FirstName.MaxLength + 1); - - Result result = FirstName.Create(input); - - Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.FirstName.LongerThanAllowed, result.Error); + Assert.Equal(expected, result.Error); } } diff --git a/tests/Domain.UnitTests/Contacts/LastNameTests.cs b/tests/Domain.UnitTests/Contacts/LastNameTests.cs index 13fb04a..9b87377 100644 --- a/tests/Domain.UnitTests/Contacts/LastNameTests.cs +++ b/tests/Domain.UnitTests/Contacts/LastNameTests.cs @@ -1,44 +1,28 @@ using Domain.Contacts; using Domain.Core.Primitives; +using TestData.LastNames.Cases; -namespace Domain.UnitTests.Contacts; +namespace UnitTests.Contacts; public class LastNameTests { - [Fact] - public void Create_Should_ReturnSuccess_WithValidInput() + [Theory] + [ClassData(typeof(LastNameValid))] + public void Create_Should_ReturnSuccess_WithValidInput(string input) { - string input = "Doe"; - Result result = LastName.Create(input); Assert.True(result.IsSuccess); - Assert.False(result.IsFailure); Assert.Equal(input, result.Value); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Create_Should_ReturnError_WithNullOrEmptyInput(string? input) + [ClassData(typeof(LastNameInvalid))] + public void Create_Should_ReturnError_WithInvalidInput(string? input, Error expected) { Result result = LastName.Create(input); Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.LastName.NullOrEmpty, result.Error); - } - - [Fact] - public void Create_Should_ReturnError_WithTooLongInput() - { - string input = new('a', LastName.MaxLength + 1); - - Result result = LastName.Create(input); - - Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.LastName.LongerThanAllowed, result.Error); + Assert.Equal(expected, result.Error); } } diff --git a/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs b/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs index 10f107a..d41c288 100644 --- a/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs +++ b/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs @@ -1,68 +1,28 @@ using Domain.Contacts; using Domain.Core.Primitives; +using TestData.PhoneNumbers.Cases; -namespace Domain.UnitTests.Contacts; +namespace UnitTests.Contacts; public class PhoneNumberTests { - [Fact] - public void Create_Should_ReturnSuccess_WithValidInput() + [Theory] + [ClassData(typeof(PhoneNumberValid))] + public void Create_Should_ReturnSuccess_WithValidInput(string input) { - string input = "0919876543"; - Result result = PhoneNumber.Create(input); Assert.True(result.IsSuccess); - Assert.False(result.IsFailure); Assert.Equal(input, result.Value); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Create_Should_ReturnError_WithNullOrEmptyInput(string? input) - { - Result result = PhoneNumber.Create(input); - - Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.PhoneNumber.NullOrEmpty, result.Error); - } - - [Fact] - public void Create_Should_ReturnError_WithTooLongInput() + [ClassData(typeof(PhoneNumberInvalid))] + public void Create_Should_ReturnError_WithInvalidInput(string? input, Error expected) { - string input = new('a', PhoneNumber.MaxLength + 1); - - Result result = PhoneNumber.Create(input); - - Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.PhoneNumber.LongerThanAllowed, result.Error); - } - - [Fact] - public void Create_Should_ReturnError_WithTooShortInput() - { - string input = new('a', PhoneNumber.MinLength - 1); - - Result result = PhoneNumber.Create(input); - - Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.PhoneNumber.ShorterThanAllowed, result.Error); - } - - [Fact] - public void Create_Should_ReturnError_WithInvalidFormatInput() - { - string input = "091987654a"; - Result result = PhoneNumber.Create(input); Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(ContactErrors.PhoneNumber.InvalidFormat, result.Error); + Assert.Equal(expected, result.Error); } } diff --git a/tests/Domain.UnitTests/Pagination/PageSizeTests.cs b/tests/Domain.UnitTests/Pagination/PageSizeTests.cs index 06b76e1..2270ac3 100644 --- a/tests/Domain.UnitTests/Pagination/PageSizeTests.cs +++ b/tests/Domain.UnitTests/Pagination/PageSizeTests.cs @@ -1,43 +1,28 @@ using Domain.Core.Pagination; using Domain.Core.Primitives; +using TestData.Pagination.Cases; -namespace Domain.UnitTests.Pagination; +namespace UnitTests.Pagination; public class PageSizeTests { - [Fact] - public void Create_Should_ReturnSuccess_WithValidInput() + [Theory] + [ClassData(typeof(PageSizeValid))] + public void Create_Should_ReturnSuccess_WithValidInput(int input) { - int input = 10; - Result result = PageSize.Create(input); Assert.True(result.IsSuccess); - Assert.False(result.IsFailure); Assert.Equal(input, result.Value); } - [Fact] - public void Create_Should_ReturnError_WithTooLowInput() + [Theory] + [ClassData(typeof(PageSizeInvalid))] + public void Create_Should_ReturnError_WithInvalidInput(int input, Error expected) { - int input = PageSize.MinimumPageSize - 1; - - Result result = PageSize.Create(input); - - Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(PaginationErrors.LowerThanAllowed, result.Error); - } - - [Fact] - public void Create_Should_ReturnError_WithTooHighInput() - { - int input = PageSize.MaximumPageSize + 1; - Result result = PageSize.Create(input); Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(PaginationErrors.GreaterThanAllowed, result.Error); + Assert.Equal(expected, result.Error); } } diff --git a/tests/Domain.UnitTests/Pagination/PageTests.cs b/tests/Domain.UnitTests/Pagination/PageTests.cs index 8efcec1..f58a688 100644 --- a/tests/Domain.UnitTests/Pagination/PageTests.cs +++ b/tests/Domain.UnitTests/Pagination/PageTests.cs @@ -1,7 +1,7 @@ using Domain.Core.Pagination; using Domain.Core.Primitives; -namespace Domain.UnitTests.Pagination; +namespace UnitTests.Pagination; public class PageTests { diff --git a/tests/Domain.UnitTests/Pagination/PagedListTests.cs b/tests/Domain.UnitTests/Pagination/PagedListTests.cs index 927a90e..f4d8476 100644 --- a/tests/Domain.UnitTests/Pagination/PagedListTests.cs +++ b/tests/Domain.UnitTests/Pagination/PagedListTests.cs @@ -1,85 +1,30 @@ using Domain.Core.Pagination; -using Domain.Core.Primitives; +using TestData.Pagination.Cases; -namespace Domain.UnitTests.Pagination; +namespace UnitTests.Pagination; public class PagedListTests { - private static readonly List _items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; - private static readonly Result _firstPage = Page.Create(1); - private static readonly Result _pageSize = PageSize.Create(10); - private static readonly int _totalCount = _items.Count; - - [Fact] - public void Constructor_Should_SetProperties() - { - PagedList pagedList = new( - _items, - _firstPage.Value, - _pageSize.Value, - _totalCount - ); - - Assert.Equal(_firstPage.Value, pagedList.Page); - Assert.Equal(_pageSize.Value, pagedList.PageSize); - Assert.Equal(_totalCount, pagedList.TotalCount); - - Assert.Equal(_items.Count, pagedList.Items.Count); - Assert.Equal(_items, pagedList.Items); - } - - [Fact] - public void HasNextPage_Should_ReturnTrue_WhenNextPageExists() - { - PagedList pagedList = new( - _items, - _firstPage.Value, - _pageSize.Value, - _totalCount - ); - - Assert.True(pagedList.HasNextPage); - } - - [Fact] - public void HasNextPage_Should_ReturnFalse_WhenNextPageDoesNotExist() + [Theory] + [ClassData(typeof(PagedListValid))] + public void Constructor_Should_SetProperties( + List items, + Page page, + PageSize pageSize, + int totalCount, + int totalPages, + bool hasNextPage, + bool hasPreviousPage + ) { - Result lastPage = Page.Create(2); - - PagedList pagedList = new( - _items, - lastPage.Value, - _pageSize.Value, - _totalCount - ); - - Assert.False(pagedList.HasNextPage); - } - - [Fact] - public void HasPreviousPage_Should_ReturnFalse_WhenOnFirstPage() - { - PagedList pagedList = new( - _items, - _firstPage.Value, - _pageSize.Value, - _totalCount - ); - - Assert.False(pagedList.HasPreviousPage); - } - - [Fact] - public void HasPreviousPage_Should_ReturnTrue_WhenNotOnFirstPage() - { - Result secondPage = Page.Create(2); - PagedList pagedList = new( - _items, - secondPage.Value, - _pageSize.Value, - _totalCount - ); - - Assert.True(pagedList.HasPreviousPage); + PagedList pagedList = new(items, page, pageSize, totalCount); + + Assert.Equal(items, pagedList.Items); + Assert.Equal(page, pagedList.Page); + Assert.Equal(pageSize, pagedList.PageSize); + Assert.Equal(items.Count, pagedList.TotalCount); + Assert.Equal(totalPages, pagedList.TotalPages); + Assert.Equal(hasNextPage, pagedList.HasNextPage); + Assert.Equal(hasPreviousPage, pagedList.HasPreviousPage); } } diff --git a/tests/Domain.UnitTests/Domain.UnitTests.csproj b/tests/Domain.UnitTests/UnitTests.csproj similarity index 87% rename from tests/Domain.UnitTests/Domain.UnitTests.csproj rename to tests/Domain.UnitTests/UnitTests.csproj index 68d9a4d..a800330 100644 --- a/tests/Domain.UnitTests/Domain.UnitTests.csproj +++ b/tests/Domain.UnitTests/UnitTests.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/TestData/Contacts/ContactData.cs b/tests/TestData/Contacts/ContactData.cs index 799fce7..4dbbc15 100644 --- a/tests/TestData/Contacts/ContactData.cs +++ b/tests/TestData/Contacts/ContactData.cs @@ -1,4 +1,8 @@ using Domain.Contacts; +using TestData.Emails; +using TestData.FirstNames; +using TestData.LastNames; +using TestData.PhoneNumbers; namespace TestData.Contacts; diff --git a/tests/TestData/Contacts/ContactUpdate.cs b/tests/TestData/Contacts/ContactUpdate.cs new file mode 100644 index 0000000..89dc374 --- /dev/null +++ b/tests/TestData/Contacts/ContactUpdate.cs @@ -0,0 +1,17 @@ +using Domain.Contacts; +using Xunit; + +namespace TestData.Contacts; + +public class ContactUpdate : TheoryData +{ + public ContactUpdate() + { + Add( + FirstName.Create("Ada").Value, + LastName.Create("Lovelace").Value, + Email.Create("alovelace@gmail.com").Value, + PhoneNumber.Create("0913456789").Value + ); + } +} diff --git a/tests/TestData/Contacts/ContactValid.cs b/tests/TestData/Contacts/ContactValid.cs new file mode 100644 index 0000000..76fb6e6 --- /dev/null +++ b/tests/TestData/Contacts/ContactValid.cs @@ -0,0 +1,22 @@ +using Domain.Contacts; +using TestData.Emails; +using TestData.FirstNames; +using TestData.LastNames; +using TestData.PhoneNumbers; +using Xunit; + +namespace TestData.Contacts; + +public class ContactValid : TheoryData +{ + public ContactValid() + { + Add( + FirstNameData.ValidFirstName, + LastNameData.ValidLastName, + EmailData.ValidEmail, + PhoneNumberData.ValidPhoneNumber, + $"{FirstNameData.ValidFirstName.Value} {LastNameData.ValidLastName.Value}" + ); + } +} diff --git a/tests/TestData/Contacts/Create/CreateContactRequestData.cs b/tests/TestData/Contacts/Create/CreateContactRequestData.cs index c965b9f..dea1d0a 100644 --- a/tests/TestData/Contacts/Create/CreateContactRequestData.cs +++ b/tests/TestData/Contacts/Create/CreateContactRequestData.cs @@ -1,3 +1,7 @@ +using TestData.Emails; +using TestData.FirstNames; +using TestData.LastNames; +using TestData.PhoneNumbers; using WebApi.Contacts.Create; namespace TestData.Contacts.Create; diff --git a/tests/TestData/Contacts/Update/UpdateContactRequestData.cs b/tests/TestData/Contacts/Update/UpdateContactRequestData.cs index cf00b0a..97e3247 100644 --- a/tests/TestData/Contacts/Update/UpdateContactRequestData.cs +++ b/tests/TestData/Contacts/Update/UpdateContactRequestData.cs @@ -1,3 +1,7 @@ +using TestData.Emails; +using TestData.FirstNames; +using TestData.LastNames; +using TestData.PhoneNumbers; using WebApi.Contacts.Update; namespace TestData.Contacts.Update; diff --git a/tests/TestData/Emails/Cases/EmailInvalid.cs b/tests/TestData/Emails/Cases/EmailInvalid.cs new file mode 100644 index 0000000..0cbedb8 --- /dev/null +++ b/tests/TestData/Emails/Cases/EmailInvalid.cs @@ -0,0 +1,17 @@ +using Domain.Contacts; +using Domain.Core.Primitives; +using Xunit; + +namespace TestData.Emails.Cases; + +public class EmailInvalid : TheoryData +{ + public EmailInvalid() + { + Add(null, ContactErrors.Email.NullOrEmpty); + Add(string.Empty, ContactErrors.Email.NullOrEmpty); + Add(" ", ContactErrors.Email.NullOrEmpty); + Add(EmailData.LongerThanAllowedEmail, ContactErrors.Email.LongerThanAllowed); + Add(EmailData.InvalidFormatEmail, ContactErrors.Email.InvalidFormat); + } +} diff --git a/tests/TestData/Emails/Cases/EmailValid.cs b/tests/TestData/Emails/Cases/EmailValid.cs new file mode 100644 index 0000000..81deb6b --- /dev/null +++ b/tests/TestData/Emails/Cases/EmailValid.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace TestData.Emails.Cases; + +public class EmailValid : TheoryData +{ + public EmailValid() + { + Add(EmailData.ValidEmail); + } +} diff --git a/tests/TestData/Contacts/EmailData.cs b/tests/TestData/Emails/EmailData.cs similarity index 91% rename from tests/TestData/Contacts/EmailData.cs rename to tests/TestData/Emails/EmailData.cs index 9c86bd4..163e405 100644 --- a/tests/TestData/Contacts/EmailData.cs +++ b/tests/TestData/Emails/EmailData.cs @@ -1,6 +1,6 @@ using Domain.Contacts; -namespace TestData.Contacts; +namespace TestData.Emails; public static class EmailData { diff --git a/tests/TestData/FirstNames/Cases/FirstNameInvalid.cs b/tests/TestData/FirstNames/Cases/FirstNameInvalid.cs new file mode 100644 index 0000000..5bebbf6 --- /dev/null +++ b/tests/TestData/FirstNames/Cases/FirstNameInvalid.cs @@ -0,0 +1,19 @@ +using Domain.Contacts; +using Domain.Core.Primitives; +using Xunit; + +namespace TestData.FirstNames.Cases; + +public class FirstNameInvalid : TheoryData +{ + public FirstNameInvalid() + { + Add(null, ContactErrors.FirstName.NullOrEmpty); + Add(string.Empty, ContactErrors.FirstName.NullOrEmpty); + Add(" ", ContactErrors.FirstName.NullOrEmpty); + Add( + FirstNameData.LongerThanAllowedFirstName, + ContactErrors.FirstName.LongerThanAllowed + ); + } +} diff --git a/tests/TestData/FirstNames/Cases/FirstNameValid.cs b/tests/TestData/FirstNames/Cases/FirstNameValid.cs new file mode 100644 index 0000000..a3e78a7 --- /dev/null +++ b/tests/TestData/FirstNames/Cases/FirstNameValid.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace TestData.FirstNames.Cases; + +public class FirstNameValid : TheoryData +{ + public FirstNameValid() + { + Add(FirstNameData.ValidFirstName); + } +} diff --git a/tests/TestData/Contacts/FirstNameData.cs b/tests/TestData/FirstNames/FirstNameData.cs similarity index 90% rename from tests/TestData/Contacts/FirstNameData.cs rename to tests/TestData/FirstNames/FirstNameData.cs index 6a080cc..277659b 100644 --- a/tests/TestData/Contacts/FirstNameData.cs +++ b/tests/TestData/FirstNames/FirstNameData.cs @@ -1,6 +1,6 @@ using Domain.Contacts; -namespace TestData.Contacts; +namespace TestData.FirstNames; public static class FirstNameData { diff --git a/tests/TestData/LastNames/Cases/LastNameInvalid.cs b/tests/TestData/LastNames/Cases/LastNameInvalid.cs new file mode 100644 index 0000000..32ded71 --- /dev/null +++ b/tests/TestData/LastNames/Cases/LastNameInvalid.cs @@ -0,0 +1,19 @@ +using Domain.Contacts; +using Domain.Core.Primitives; +using Xunit; + +namespace TestData.LastNames.Cases; + +public class LastNameInvalid : TheoryData +{ + public LastNameInvalid() + { + Add(null, ContactErrors.LastName.NullOrEmpty); + Add(string.Empty, ContactErrors.LastName.NullOrEmpty); + Add(" ", ContactErrors.LastName.NullOrEmpty); + Add( + LastNameData.LongerThanAllowedLastName, + ContactErrors.LastName.LongerThanAllowed + ); + } +} diff --git a/tests/TestData/LastNames/Cases/LastNameValid.cs b/tests/TestData/LastNames/Cases/LastNameValid.cs new file mode 100644 index 0000000..50a1862 --- /dev/null +++ b/tests/TestData/LastNames/Cases/LastNameValid.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace TestData.LastNames.Cases; + +public class LastNameValid : TheoryData +{ + public LastNameValid() + { + Add(LastNameData.ValidLastName); + } +} diff --git a/tests/TestData/Contacts/LastNameData.cs b/tests/TestData/LastNames/LastNameData.cs similarity index 90% rename from tests/TestData/Contacts/LastNameData.cs rename to tests/TestData/LastNames/LastNameData.cs index 37cf7bd..ef21c8b 100644 --- a/tests/TestData/Contacts/LastNameData.cs +++ b/tests/TestData/LastNames/LastNameData.cs @@ -1,6 +1,6 @@ using Domain.Contacts; -namespace TestData.Contacts; +namespace TestData.LastNames; public static class LastNameData { diff --git a/tests/TestData/Pagination/Cases/PageSizeInvalid.cs b/tests/TestData/Pagination/Cases/PageSizeInvalid.cs new file mode 100644 index 0000000..b1322d7 --- /dev/null +++ b/tests/TestData/Pagination/Cases/PageSizeInvalid.cs @@ -0,0 +1,14 @@ +using Domain.Core.Pagination; +using Domain.Core.Primitives; +using Xunit; + +namespace TestData.Pagination.Cases; + +public class PageSizeInvalid : TheoryData +{ + public PageSizeInvalid() + { + Add(PageSizeData.GreaterThenAllowedPageSize, PaginationErrors.GreaterThanAllowed); + Add(PageSizeData.LowerThenAllowedPageSize, PaginationErrors.LowerThanAllowed); + } +} diff --git a/tests/TestData/Pagination/Cases/PageSizeValid.cs b/tests/TestData/Pagination/Cases/PageSizeValid.cs new file mode 100644 index 0000000..395876f --- /dev/null +++ b/tests/TestData/Pagination/Cases/PageSizeValid.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace TestData.Pagination.Cases; + +public class PageSizeValid : TheoryData +{ + public PageSizeValid() + { + Add(PageSizeData.MinimumPageSize); + } +} diff --git a/tests/TestData/Pagination/Cases/PagedListValid.cs b/tests/TestData/Pagination/Cases/PagedListValid.cs new file mode 100644 index 0000000..dcc635c --- /dev/null +++ b/tests/TestData/Pagination/Cases/PagedListValid.cs @@ -0,0 +1,30 @@ +using Domain.Core.Pagination; +using Xunit; + +namespace TestData.Pagination.Cases; + +public class PagedListValid : TheoryData, Page, PageSize, int, int, bool, bool> +{ + public PagedListValid() + { + Add( + PagedListData.Items, + PageData.FirstPage, + PageSizeData.MinimumPageSize, + PagedListData.Items.Count, + 2, + true, + false + ); + + Add( + PagedListData.Items, + PageData.SecondPage, + PageSizeData.MinimumPageSize, + PagedListData.Items.Count, + 2, + false, + true + ); + } +} diff --git a/tests/TestData/Pagination/PageData.cs b/tests/TestData/Pagination/PageData.cs index ade228b..d086414 100644 --- a/tests/TestData/Pagination/PageData.cs +++ b/tests/TestData/Pagination/PageData.cs @@ -4,7 +4,8 @@ namespace TestData.Pagination; public static class PageData { - public static readonly Page ValidPage = Page.Create(1).Value; + public static readonly Page FirstPage = Page.Create(1).Value; + public static readonly Page SecondPage = Page.Create(2).Value; public static readonly int InvalidPage = -1; } diff --git a/tests/TestData/Pagination/PageSizeData.cs b/tests/TestData/Pagination/PageSizeData.cs index ba1f7da..fc9f0ed 100644 --- a/tests/TestData/Pagination/PageSizeData.cs +++ b/tests/TestData/Pagination/PageSizeData.cs @@ -4,7 +4,7 @@ namespace TestData.Pagination; public static class PageSizeData { - public static readonly PageSize ValidPageSize = PageSize + public static readonly PageSize MinimumPageSize = PageSize .Create(PageSize.MinimumPageSize) .Value; diff --git a/tests/TestData/Pagination/PagedListData.cs b/tests/TestData/Pagination/PagedListData.cs new file mode 100644 index 0000000..7fdaa56 --- /dev/null +++ b/tests/TestData/Pagination/PagedListData.cs @@ -0,0 +1,6 @@ +namespace TestData.Pagination; + +public static class PagedListData +{ + public static readonly List Items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +} diff --git a/tests/TestData/PhoneNumbers/Cases/PhoneNumberInvalid.cs b/tests/TestData/PhoneNumbers/Cases/PhoneNumberInvalid.cs new file mode 100644 index 0000000..305d2d3 --- /dev/null +++ b/tests/TestData/PhoneNumbers/Cases/PhoneNumberInvalid.cs @@ -0,0 +1,27 @@ +using Domain.Contacts; +using Domain.Core.Primitives; +using Xunit; + +namespace TestData.PhoneNumbers.Cases; + +public class PhoneNumberInvalid : TheoryData +{ + public PhoneNumberInvalid() + { + Add(null, ContactErrors.PhoneNumber.NullOrEmpty); + Add(string.Empty, ContactErrors.PhoneNumber.NullOrEmpty); + Add(" ", ContactErrors.PhoneNumber.NullOrEmpty); + Add( + PhoneNumberData.LongerThanAllowedPhoneNumber, + ContactErrors.PhoneNumber.LongerThanAllowed + ); + Add( + PhoneNumberData.ShorterThanAllowedPhoneNumber, + ContactErrors.PhoneNumber.ShorterThanAllowed + ); + Add( + PhoneNumberData.InvalidFormatPhoneNumber, + ContactErrors.PhoneNumber.InvalidFormat + ); + } +} diff --git a/tests/TestData/PhoneNumbers/Cases/PhoneNumberValid.cs b/tests/TestData/PhoneNumbers/Cases/PhoneNumberValid.cs new file mode 100644 index 0000000..1619412 --- /dev/null +++ b/tests/TestData/PhoneNumbers/Cases/PhoneNumberValid.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace TestData.PhoneNumbers.Cases; + +public class PhoneNumberValid : TheoryData +{ + public PhoneNumberValid() + { + Add(PhoneNumberData.ValidPhoneNumber); + } +} diff --git a/tests/TestData/Contacts/PhoneNumberData.cs b/tests/TestData/PhoneNumbers/PhoneNumberData.cs similarity index 78% rename from tests/TestData/Contacts/PhoneNumberData.cs rename to tests/TestData/PhoneNumbers/PhoneNumberData.cs index 9eec34f..d5b7dd6 100644 --- a/tests/TestData/Contacts/PhoneNumberData.cs +++ b/tests/TestData/PhoneNumbers/PhoneNumberData.cs @@ -1,6 +1,6 @@ using Domain.Contacts; -namespace TestData.Contacts; +namespace TestData.PhoneNumbers; public static class PhoneNumberData { @@ -17,4 +17,6 @@ public static class PhoneNumberData '*', PhoneNumber.MinLength - 1 ); + + public static readonly string InvalidFormatPhoneNumber = new("091-1234/567"); } diff --git a/tests/TestData/TestData.csproj b/tests/TestData/TestData.csproj index 5bef45d..f8f080e 100644 --- a/tests/TestData/TestData.csproj +++ b/tests/TestData/TestData.csproj @@ -6,4 +6,8 @@ + + + false + From 931d74c52bbe7c194dc42e1c29558cf3c3c3dc6c Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:04:30 +0100 Subject: [PATCH 27/46] feat: add FirstPage constant to Page (#49) * add FirstPage constant * update validation condition to use FirstPage * update Pagination constants to use Page and PageSize values * refactor PageTests to use PageData --- backend/Domain/Core/Pagination/Page.cs | 7 ++++++- backend/WebApi/Core/Constants/Pagination.cs | 6 ++++-- .../Pagination/Cases/PageInvalid.cs | 13 +++++++++++++ .../Pagination/Cases/PageValid.cs | 12 ++++++++++++ .../Domain.UnitTests/Pagination/PageTests.cs | 19 ++++++++----------- tests/TestData/Pagination/PageData.cs | 4 ++-- 6 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 tests/Domain.UnitTests/Pagination/Cases/PageInvalid.cs create mode 100644 tests/Domain.UnitTests/Pagination/Cases/PageValid.cs diff --git a/backend/Domain/Core/Pagination/Page.cs b/backend/Domain/Core/Pagination/Page.cs index 770051c..ef0850c 100644 --- a/backend/Domain/Core/Pagination/Page.cs +++ b/backend/Domain/Core/Pagination/Page.cs @@ -7,6 +7,11 @@ namespace Domain.Core.Pagination; /// public sealed record Page { + /// + /// The first page. + /// + public const int FirstPage = 1; + private Page(int value) { Value = value; @@ -33,7 +38,7 @@ public static implicit operator int(Page page) /// The result of the page creation process containing the page or an error. public static Result Create(int page) { - if (page < 1) + if (page < FirstPage) { return PaginationErrors.InvalidPage; } diff --git a/backend/WebApi/Core/Constants/Pagination.cs b/backend/WebApi/Core/Constants/Pagination.cs index 2db55a9..bca9c47 100644 --- a/backend/WebApi/Core/Constants/Pagination.cs +++ b/backend/WebApi/Core/Constants/Pagination.cs @@ -1,3 +1,5 @@ +using Domain.Core.Pagination; + namespace WebApi.Core.Constants; /// @@ -8,12 +10,12 @@ public static class Pagination /// /// Default page. /// - public const int DefaultPage = 1; + public const int DefaultPage = Page.FirstPage; /// /// Default page size. /// - public const int DefaultPageSize = 10; + public const int DefaultPageSize = PageSize.MinimumPageSize; /// /// Default sort order. diff --git a/tests/Domain.UnitTests/Pagination/Cases/PageInvalid.cs b/tests/Domain.UnitTests/Pagination/Cases/PageInvalid.cs new file mode 100644 index 0000000..5bf0131 --- /dev/null +++ b/tests/Domain.UnitTests/Pagination/Cases/PageInvalid.cs @@ -0,0 +1,13 @@ +using Domain.Core.Pagination; +using Domain.Core.Primitives; +using TestData.Pagination; + +namespace UnitTests.Pagination.Cases; + +public class PageInvalid : TheoryData +{ + public PageInvalid() + { + Add(PageData.InvalidPage, PaginationErrors.InvalidPage); + } +} diff --git a/tests/Domain.UnitTests/Pagination/Cases/PageValid.cs b/tests/Domain.UnitTests/Pagination/Cases/PageValid.cs new file mode 100644 index 0000000..e9e24aa --- /dev/null +++ b/tests/Domain.UnitTests/Pagination/Cases/PageValid.cs @@ -0,0 +1,12 @@ +using Domain.Core.Pagination; + +namespace UnitTests.Pagination.Cases; + +public class PageValid : TheoryData +{ + public PageValid() + { + Add(Page.FirstPage); + Add(Page.FirstPage + 1); + } +} diff --git a/tests/Domain.UnitTests/Pagination/PageTests.cs b/tests/Domain.UnitTests/Pagination/PageTests.cs index f58a688..21092ec 100644 --- a/tests/Domain.UnitTests/Pagination/PageTests.cs +++ b/tests/Domain.UnitTests/Pagination/PageTests.cs @@ -1,31 +1,28 @@ using Domain.Core.Pagination; using Domain.Core.Primitives; +using UnitTests.Pagination.Cases; namespace UnitTests.Pagination; public class PageTests { - [Fact] - public void Create_Should_ReturnSuccess_WithValidInput() + [Theory] + [ClassData(typeof(PageValid))] + public void Create_Should_ReturnSuccess_WithValidInput(int input) { - int input = 1; - Result result = Page.Create(input); Assert.True(result.IsSuccess); - Assert.False(result.IsFailure); Assert.Equal(input, result.Value); } - [Fact] - public void Create_Should_ReturnError_WithTooLowInput() + [Theory] + [ClassData(typeof(PageInvalid))] + public void Create_Should_ReturnError_WithTooLowInput(int input, Error expected) { - int input = 0; - Result result = Page.Create(input); Assert.True(result.IsFailure); - Assert.False(result.IsSuccess); - Assert.Equal(PaginationErrors.InvalidPage, result.Error); + Assert.Equal(expected, result.Error); } } diff --git a/tests/TestData/Pagination/PageData.cs b/tests/TestData/Pagination/PageData.cs index d086414..0c5b8c4 100644 --- a/tests/TestData/Pagination/PageData.cs +++ b/tests/TestData/Pagination/PageData.cs @@ -4,8 +4,8 @@ namespace TestData.Pagination; public static class PageData { - public static readonly Page FirstPage = Page.Create(1).Value; + public static readonly Page FirstPage = Page.Create(Page.FirstPage).Value; public static readonly Page SecondPage = Page.Create(2).Value; - public static readonly int InvalidPage = -1; + public static readonly int InvalidPage = Page.FirstPage - 1; } From c019c8f5a15ea2ac824fc11cd1fdb9736493dae2 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:19:44 +0100 Subject: [PATCH 28/46] feat: add OpenApi metadata to GetContacts endpoint (#50) --- backend/WebApi/Contacts/Get/GetContactsEndpoint.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs b/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs index 905a18a..87882dc 100644 --- a/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs +++ b/backend/WebApi/Contacts/Get/GetContactsEndpoint.cs @@ -10,9 +10,14 @@ namespace WebApi.Contacts.Get; internal sealed class GetContactsEndpoint : IEndpoint { + public const string Name = "GetContacts"; + public void MapEndpoint(IEndpointRouteBuilder app) { - app.MapGet(Routes.Contacts.Get, Handler); + app.MapGet(Routes.Contacts.Get, Handler) + .WithNameAndTags(Name, Tags.Contacts) + .Produces(StatusCodes.Status200OK) + .MapToApiVersion(1); } private static async Task Handler( From 9dfdc55b7df79d327d3ad40a1eb95c964b149b3f Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:32:38 +0100 Subject: [PATCH 29/46] test: add runsettings file (#51) * add .runsettings file with code coverage exclusion rules * add VS Code workspace settings to include .runsettings --- .runsettings | 19 +++++++++++++++++++ .vscode/settings.json | 3 +++ 2 files changed, 22 insertions(+) create mode 100644 .runsettings create mode 100644 .vscode/settings.json diff --git a/.runsettings b/.runsettings new file mode 100644 index 0000000..c86e812 --- /dev/null +++ b/.runsettings @@ -0,0 +1,19 @@ + + + + + + + + + + .*/Migrations/.*\.cs$ + .*/OpenApiXmlCommentSupport\.generated\.cs$ + + + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a99789a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.unitTests.runSettingsPath": ".runsettings" +} From 440636e6daa9846c092ebf616fab3220e8d0503f Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:21:14 +0100 Subject: [PATCH 30/46] test: move testing cases to test projects (#52) * move FirstName test cases to UnitTests project * move FirstName data to Contacts folder * move LastName test cases to UnitTests project * move Email test cases to UnitTests project * move PhoneNumber test cases to UnitTests project * move Contact test cases to UnitTests project * move Pagination test cases to UnitTests project * move CreateContact test cases to IntegrationTests project * move UpdateContact test cases to IntegrationTests project * move GetContacts test cases to IntegrationTests project * remove XUnit dependency from TestData project * add GlobalUsings file to UnitTests project * add GlobalUsings file to TestData project * add GlobalUsings file to IntegrationTests project --- .../Contacts/Cases}/ContactUpdate.cs | 5 +---- .../Contacts/Cases}/ContactValid.cs | 9 ++------- .../Contacts/Cases/Emails}/EmailInvalid.cs | 6 ++---- .../Contacts/Cases/Emails}/EmailValid.cs | 4 ++-- .../Contacts/Cases/FirstNames}/FirstNameInvalid.cs | 6 ++---- .../Contacts/Cases/FirstNames}/FirstNameValid.cs | 4 ++-- .../Contacts/Cases/LastNames}/LastNameInvalid.cs | 6 ++---- .../Contacts/Cases/LastNames}/LastNameValid.cs | 4 ++-- .../Cases/PhoneNumbers}/PhoneNumberInvalid.cs | 6 ++---- .../Cases/PhoneNumbers}/PhoneNumberValid.cs | 4 ++-- tests/Domain.UnitTests/Contacts/ContactTests.cs | 2 +- tests/Domain.UnitTests/Contacts/EmailTests.cs | 4 +--- tests/Domain.UnitTests/Contacts/FirstNameTests.cs | 4 +--- tests/Domain.UnitTests/Contacts/LastNameTests.cs | 4 +--- .../Domain.UnitTests/Contacts/PhoneNumberTests.cs | 4 +--- tests/Domain.UnitTests/GlobalUsings.cs | 3 +++ .../Pagination/Cases/PageSizeInvalid.cs | 4 ++-- .../Pagination/Cases/PageSizeValid.cs | 4 ++-- .../Pagination/Cases/PagedListValid.cs | 4 ++-- tests/Domain.UnitTests/Pagination/PageSizeTests.cs | 4 +--- tests/Domain.UnitTests/Pagination/PageTests.cs | 2 -- .../Domain.UnitTests/Pagination/PagedListTests.cs | 3 +-- .../Cases}/Create/CreateContactInvalidData.cs | 6 ++---- .../Cases}/Create/CreateContactValidData.cs | 4 ++-- .../Contacts/Cases}/Get/GetContactsInvalidData.cs | 6 ++---- .../Contacts/Cases}/Get/GetContactsValidData.cs | 4 ++-- .../Cases}/Update/UpdateContactInvalidData.cs | 6 ++---- .../Cases}/Update/UpdateContactValidData.cs | 4 ++-- .../Update/UpdateContactWithNonExistingIdData.cs | 6 ++---- .../Contacts/CreateContactTests.cs | 10 ++++------ .../Contacts/DeleteContactTests.cs | 4 ---- .../Contacts/GetContactByIdTests.cs | 4 ---- .../IntegrationTests/Contacts/GetContactsTests.cs | 14 +++++--------- .../Contacts/UpdateContactTests.cs | 10 ++++------ .../Core/Abstractions/BaseIntegrationTest.cs | 2 -- .../Abstractions/IntegrationTestWebAppFactory.cs | 7 ------- .../Extensions/HttpResponseMessageExtensions.cs | 3 --- tests/IntegrationTests/GlobalUsings.cs | 14 ++++++++++++++ tests/IntegrationTests/IntegrationTests.csproj | 4 ---- tests/TestData/Contacts/ContactData.cs | 6 ------ tests/TestData/{Emails => Contacts}/EmailData.cs | 4 +--- .../{FirstNames => Contacts}/FirstNameData.cs | 4 +--- .../{LastNames => Contacts}/LastNameData.cs | 4 +--- .../{PhoneNumbers => Contacts}/PhoneNumberData.cs | 4 +--- .../CreateContactRequestData.cs | 6 +----- .../{Get => Requests}/GetContactsRequestData.cs | 3 +-- .../UpdateContactRequestData.cs | 6 +----- tests/TestData/GlobalUsings.cs | 2 ++ tests/TestData/Pagination/PageData.cs | 2 -- tests/TestData/Pagination/PageSizeData.cs | 2 -- tests/TestData/Seeding/TestDataSeeder.cs | 1 - tests/TestData/TestData.csproj | 1 - 52 files changed, 85 insertions(+), 164 deletions(-) rename tests/{TestData/Contacts => Domain.UnitTests/Contacts/Cases}/ContactUpdate.cs (83%) rename tests/{TestData/Contacts => Domain.UnitTests/Contacts/Cases}/ContactValid.cs (70%) rename tests/{TestData/Emails/Cases => Domain.UnitTests/Contacts/Cases/Emails}/EmailInvalid.cs (81%) rename tests/{TestData/Emails/Cases => Domain.UnitTests/Contacts/Cases/Emails}/EmailValid.cs (64%) rename tests/{TestData/FirstNames/Cases => Domain.UnitTests/Contacts/Cases/FirstNames}/FirstNameInvalid.cs (80%) rename tests/{TestData/FirstNames/Cases => Domain.UnitTests/Contacts/Cases/FirstNames}/FirstNameValid.cs (65%) rename tests/{TestData/LastNames/Cases => Domain.UnitTests/Contacts/Cases/LastNames}/LastNameInvalid.cs (79%) rename tests/{TestData/LastNames/Cases => Domain.UnitTests/Contacts/Cases/LastNames}/LastNameValid.cs (65%) rename tests/{TestData/PhoneNumbers/Cases => Domain.UnitTests/Contacts/Cases/PhoneNumbers}/PhoneNumberInvalid.cs (86%) rename tests/{TestData/PhoneNumbers/Cases => Domain.UnitTests/Contacts/Cases/PhoneNumbers}/PhoneNumberValid.cs (66%) create mode 100644 tests/Domain.UnitTests/GlobalUsings.cs rename tests/{TestData => Domain.UnitTests}/Pagination/Cases/PageSizeInvalid.cs (83%) rename tests/{TestData => Domain.UnitTests}/Pagination/Cases/PageSizeValid.cs (67%) rename tests/{TestData => Domain.UnitTests}/Pagination/Cases/PagedListValid.cs (90%) rename tests/{TestData/Contacts => IntegrationTests/Contacts/Cases}/Create/CreateContactInvalidData.cs (88%) rename tests/{TestData/Contacts => IntegrationTests/Contacts/Cases}/Create/CreateContactValidData.cs (71%) rename tests/{TestData/Contacts => IntegrationTests/Contacts/Cases}/Get/GetContactsInvalidData.cs (84%) rename tests/{TestData/Contacts => IntegrationTests/Contacts/Cases}/Get/GetContactsValidData.cs (83%) rename tests/{TestData/Contacts => IntegrationTests/Contacts/Cases}/Update/UpdateContactInvalidData.cs (88%) rename tests/{TestData/Contacts => IntegrationTests/Contacts/Cases}/Update/UpdateContactValidData.cs (71%) rename tests/{TestData/Contacts => IntegrationTests/Contacts/Cases}/Update/UpdateContactWithNonExistingIdData.cs (79%) create mode 100644 tests/IntegrationTests/GlobalUsings.cs rename tests/TestData/{Emails => Contacts}/EmailData.cs (85%) rename tests/TestData/{FirstNames => Contacts}/FirstNameData.cs (83%) rename tests/TestData/{LastNames => Contacts}/LastNameData.cs (83%) rename tests/TestData/{PhoneNumbers => Contacts}/PhoneNumberData.cs (89%) rename tests/TestData/Contacts/{Create => Requests}/CreateContactRequestData.cs (91%) rename tests/TestData/Contacts/{Get => Requests}/GetContactsRequestData.cs (92%) rename tests/TestData/Contacts/{Update => Requests}/UpdateContactRequestData.cs (91%) create mode 100644 tests/TestData/GlobalUsings.cs diff --git a/tests/TestData/Contacts/ContactUpdate.cs b/tests/Domain.UnitTests/Contacts/Cases/ContactUpdate.cs similarity index 83% rename from tests/TestData/Contacts/ContactUpdate.cs rename to tests/Domain.UnitTests/Contacts/Cases/ContactUpdate.cs index 89dc374..53d69e8 100644 --- a/tests/TestData/Contacts/ContactUpdate.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/ContactUpdate.cs @@ -1,7 +1,4 @@ -using Domain.Contacts; -using Xunit; - -namespace TestData.Contacts; +namespace UnitTests.Contacts.Cases; public class ContactUpdate : TheoryData { diff --git a/tests/TestData/Contacts/ContactValid.cs b/tests/Domain.UnitTests/Contacts/Cases/ContactValid.cs similarity index 70% rename from tests/TestData/Contacts/ContactValid.cs rename to tests/Domain.UnitTests/Contacts/Cases/ContactValid.cs index 76fb6e6..33e7177 100644 --- a/tests/TestData/Contacts/ContactValid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/ContactValid.cs @@ -1,11 +1,6 @@ -using Domain.Contacts; -using TestData.Emails; -using TestData.FirstNames; -using TestData.LastNames; -using TestData.PhoneNumbers; -using Xunit; +using TestData.Contacts; -namespace TestData.Contacts; +namespace UnitTests.Contacts.Cases; public class ContactValid : TheoryData { diff --git a/tests/TestData/Emails/Cases/EmailInvalid.cs b/tests/Domain.UnitTests/Contacts/Cases/Emails/EmailInvalid.cs similarity index 81% rename from tests/TestData/Emails/Cases/EmailInvalid.cs rename to tests/Domain.UnitTests/Contacts/Cases/Emails/EmailInvalid.cs index 0cbedb8..8d20fbb 100644 --- a/tests/TestData/Emails/Cases/EmailInvalid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/Emails/EmailInvalid.cs @@ -1,8 +1,6 @@ -using Domain.Contacts; -using Domain.Core.Primitives; -using Xunit; +using TestData.Contacts; -namespace TestData.Emails.Cases; +namespace UnitTests.Contacts.Cases.Emails; public class EmailInvalid : TheoryData { diff --git a/tests/TestData/Emails/Cases/EmailValid.cs b/tests/Domain.UnitTests/Contacts/Cases/Emails/EmailValid.cs similarity index 64% rename from tests/TestData/Emails/Cases/EmailValid.cs rename to tests/Domain.UnitTests/Contacts/Cases/Emails/EmailValid.cs index 81deb6b..fe66403 100644 --- a/tests/TestData/Emails/Cases/EmailValid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/Emails/EmailValid.cs @@ -1,6 +1,6 @@ -using Xunit; +using TestData.Contacts; -namespace TestData.Emails.Cases; +namespace UnitTests.Contacts.Cases.Emails; public class EmailValid : TheoryData { diff --git a/tests/TestData/FirstNames/Cases/FirstNameInvalid.cs b/tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameInvalid.cs similarity index 80% rename from tests/TestData/FirstNames/Cases/FirstNameInvalid.cs rename to tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameInvalid.cs index 5bebbf6..b07cecc 100644 --- a/tests/TestData/FirstNames/Cases/FirstNameInvalid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameInvalid.cs @@ -1,8 +1,6 @@ -using Domain.Contacts; -using Domain.Core.Primitives; -using Xunit; +using TestData.Contacts; -namespace TestData.FirstNames.Cases; +namespace UnitTests.Contacts.Cases.FirstNames; public class FirstNameInvalid : TheoryData { diff --git a/tests/TestData/FirstNames/Cases/FirstNameValid.cs b/tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameValid.cs similarity index 65% rename from tests/TestData/FirstNames/Cases/FirstNameValid.cs rename to tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameValid.cs index a3e78a7..14bb23a 100644 --- a/tests/TestData/FirstNames/Cases/FirstNameValid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameValid.cs @@ -1,6 +1,6 @@ -using Xunit; +using TestData.Contacts; -namespace TestData.FirstNames.Cases; +namespace UnitTests.Contacts.Cases.FirstNames; public class FirstNameValid : TheoryData { diff --git a/tests/TestData/LastNames/Cases/LastNameInvalid.cs b/tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameInvalid.cs similarity index 79% rename from tests/TestData/LastNames/Cases/LastNameInvalid.cs rename to tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameInvalid.cs index 32ded71..8d9bb5c 100644 --- a/tests/TestData/LastNames/Cases/LastNameInvalid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameInvalid.cs @@ -1,8 +1,6 @@ -using Domain.Contacts; -using Domain.Core.Primitives; -using Xunit; +using TestData.Contacts; -namespace TestData.LastNames.Cases; +namespace UnitTests.Contacts.Cases.LastNames; public class LastNameInvalid : TheoryData { diff --git a/tests/TestData/LastNames/Cases/LastNameValid.cs b/tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameValid.cs similarity index 65% rename from tests/TestData/LastNames/Cases/LastNameValid.cs rename to tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameValid.cs index 50a1862..47de13f 100644 --- a/tests/TestData/LastNames/Cases/LastNameValid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameValid.cs @@ -1,6 +1,6 @@ -using Xunit; +using TestData.Contacts; -namespace TestData.LastNames.Cases; +namespace UnitTests.Contacts.Cases.LastNames; public class LastNameValid : TheoryData { diff --git a/tests/TestData/PhoneNumbers/Cases/PhoneNumberInvalid.cs b/tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs similarity index 86% rename from tests/TestData/PhoneNumbers/Cases/PhoneNumberInvalid.cs rename to tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs index 305d2d3..caae8f8 100644 --- a/tests/TestData/PhoneNumbers/Cases/PhoneNumberInvalid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs @@ -1,8 +1,6 @@ -using Domain.Contacts; -using Domain.Core.Primitives; -using Xunit; +using TestData.Contacts; -namespace TestData.PhoneNumbers.Cases; +namespace UnitTests.Contacts.Cases.PhoneNumbers; public class PhoneNumberInvalid : TheoryData { diff --git a/tests/TestData/PhoneNumbers/Cases/PhoneNumberValid.cs b/tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs similarity index 66% rename from tests/TestData/PhoneNumbers/Cases/PhoneNumberValid.cs rename to tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs index 1619412..6cd7e8d 100644 --- a/tests/TestData/PhoneNumbers/Cases/PhoneNumberValid.cs +++ b/tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs @@ -1,6 +1,6 @@ -using Xunit; +using TestData.Contacts; -namespace TestData.PhoneNumbers.Cases; +namespace UnitTests.Contacts.Cases.PhoneNumbers; public class PhoneNumberValid : TheoryData { diff --git a/tests/Domain.UnitTests/Contacts/ContactTests.cs b/tests/Domain.UnitTests/Contacts/ContactTests.cs index 81d0fdd..4af618d 100644 --- a/tests/Domain.UnitTests/Contacts/ContactTests.cs +++ b/tests/Domain.UnitTests/Contacts/ContactTests.cs @@ -1,5 +1,5 @@ -using Domain.Contacts; using TestData.Contacts; +using UnitTests.Contacts.Cases; namespace UnitTests.Contacts; diff --git a/tests/Domain.UnitTests/Contacts/EmailTests.cs b/tests/Domain.UnitTests/Contacts/EmailTests.cs index 2c0725f..5c91d0a 100644 --- a/tests/Domain.UnitTests/Contacts/EmailTests.cs +++ b/tests/Domain.UnitTests/Contacts/EmailTests.cs @@ -1,6 +1,4 @@ -using Domain.Contacts; -using Domain.Core.Primitives; -using TestData.Emails.Cases; +using UnitTests.Contacts.Cases.Emails; namespace UnitTests.Contacts; diff --git a/tests/Domain.UnitTests/Contacts/FirstNameTests.cs b/tests/Domain.UnitTests/Contacts/FirstNameTests.cs index bd2a081..384450a 100644 --- a/tests/Domain.UnitTests/Contacts/FirstNameTests.cs +++ b/tests/Domain.UnitTests/Contacts/FirstNameTests.cs @@ -1,6 +1,4 @@ -using Domain.Contacts; -using Domain.Core.Primitives; -using TestData.FirstNames.Cases; +using UnitTests.Contacts.Cases.FirstNames; namespace UnitTests.Contacts; diff --git a/tests/Domain.UnitTests/Contacts/LastNameTests.cs b/tests/Domain.UnitTests/Contacts/LastNameTests.cs index 9b87377..1c49ebc 100644 --- a/tests/Domain.UnitTests/Contacts/LastNameTests.cs +++ b/tests/Domain.UnitTests/Contacts/LastNameTests.cs @@ -1,6 +1,4 @@ -using Domain.Contacts; -using Domain.Core.Primitives; -using TestData.LastNames.Cases; +using UnitTests.Contacts.Cases.LastNames; namespace UnitTests.Contacts; diff --git a/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs b/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs index d41c288..14013c9 100644 --- a/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs +++ b/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs @@ -1,6 +1,4 @@ -using Domain.Contacts; -using Domain.Core.Primitives; -using TestData.PhoneNumbers.Cases; +using UnitTests.Contacts.Cases.PhoneNumbers; namespace UnitTests.Contacts; diff --git a/tests/Domain.UnitTests/GlobalUsings.cs b/tests/Domain.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..20af7ff --- /dev/null +++ b/tests/Domain.UnitTests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Domain.Contacts; +global using Domain.Core.Pagination; +global using Domain.Core.Primitives; diff --git a/tests/TestData/Pagination/Cases/PageSizeInvalid.cs b/tests/Domain.UnitTests/Pagination/Cases/PageSizeInvalid.cs similarity index 83% rename from tests/TestData/Pagination/Cases/PageSizeInvalid.cs rename to tests/Domain.UnitTests/Pagination/Cases/PageSizeInvalid.cs index b1322d7..351d800 100644 --- a/tests/TestData/Pagination/Cases/PageSizeInvalid.cs +++ b/tests/Domain.UnitTests/Pagination/Cases/PageSizeInvalid.cs @@ -1,8 +1,8 @@ using Domain.Core.Pagination; using Domain.Core.Primitives; -using Xunit; +using TestData.Pagination; -namespace TestData.Pagination.Cases; +namespace UnitTests.Pagination.Cases; public class PageSizeInvalid : TheoryData { diff --git a/tests/TestData/Pagination/Cases/PageSizeValid.cs b/tests/Domain.UnitTests/Pagination/Cases/PageSizeValid.cs similarity index 67% rename from tests/TestData/Pagination/Cases/PageSizeValid.cs rename to tests/Domain.UnitTests/Pagination/Cases/PageSizeValid.cs index 395876f..b9708e7 100644 --- a/tests/TestData/Pagination/Cases/PageSizeValid.cs +++ b/tests/Domain.UnitTests/Pagination/Cases/PageSizeValid.cs @@ -1,6 +1,6 @@ -using Xunit; +using TestData.Pagination; -namespace TestData.Pagination.Cases; +namespace UnitTests.Pagination.Cases; public class PageSizeValid : TheoryData { diff --git a/tests/TestData/Pagination/Cases/PagedListValid.cs b/tests/Domain.UnitTests/Pagination/Cases/PagedListValid.cs similarity index 90% rename from tests/TestData/Pagination/Cases/PagedListValid.cs rename to tests/Domain.UnitTests/Pagination/Cases/PagedListValid.cs index dcc635c..a95e847 100644 --- a/tests/TestData/Pagination/Cases/PagedListValid.cs +++ b/tests/Domain.UnitTests/Pagination/Cases/PagedListValid.cs @@ -1,7 +1,7 @@ using Domain.Core.Pagination; -using Xunit; +using TestData.Pagination; -namespace TestData.Pagination.Cases; +namespace UnitTests.Pagination.Cases; public class PagedListValid : TheoryData, Page, PageSize, int, int, bool, bool> { diff --git a/tests/Domain.UnitTests/Pagination/PageSizeTests.cs b/tests/Domain.UnitTests/Pagination/PageSizeTests.cs index 2270ac3..296c70e 100644 --- a/tests/Domain.UnitTests/Pagination/PageSizeTests.cs +++ b/tests/Domain.UnitTests/Pagination/PageSizeTests.cs @@ -1,6 +1,4 @@ -using Domain.Core.Pagination; -using Domain.Core.Primitives; -using TestData.Pagination.Cases; +using UnitTests.Pagination.Cases; namespace UnitTests.Pagination; diff --git a/tests/Domain.UnitTests/Pagination/PageTests.cs b/tests/Domain.UnitTests/Pagination/PageTests.cs index 21092ec..005d26e 100644 --- a/tests/Domain.UnitTests/Pagination/PageTests.cs +++ b/tests/Domain.UnitTests/Pagination/PageTests.cs @@ -1,5 +1,3 @@ -using Domain.Core.Pagination; -using Domain.Core.Primitives; using UnitTests.Pagination.Cases; namespace UnitTests.Pagination; diff --git a/tests/Domain.UnitTests/Pagination/PagedListTests.cs b/tests/Domain.UnitTests/Pagination/PagedListTests.cs index f4d8476..a9f9783 100644 --- a/tests/Domain.UnitTests/Pagination/PagedListTests.cs +++ b/tests/Domain.UnitTests/Pagination/PagedListTests.cs @@ -1,5 +1,4 @@ -using Domain.Core.Pagination; -using TestData.Pagination.Cases; +using UnitTests.Pagination.Cases; namespace UnitTests.Pagination; diff --git a/tests/TestData/Contacts/Create/CreateContactInvalidData.cs b/tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalidData.cs similarity index 88% rename from tests/TestData/Contacts/Create/CreateContactInvalidData.cs rename to tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalidData.cs index accb789..6870851 100644 --- a/tests/TestData/Contacts/Create/CreateContactInvalidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalidData.cs @@ -1,9 +1,7 @@ -using Domain.Contacts; -using Domain.Core.Primitives; +using TestData.Contacts.Requests; using WebApi.Contacts.Create; -using Xunit; -namespace TestData.Contacts.Create; +namespace IntegrationTests.Contacts.Cases.Create; public class CreateContactInvalidData : TheoryData { diff --git a/tests/TestData/Contacts/Create/CreateContactValidData.cs b/tests/IntegrationTests/Contacts/Cases/Create/CreateContactValidData.cs similarity index 71% rename from tests/TestData/Contacts/Create/CreateContactValidData.cs rename to tests/IntegrationTests/Contacts/Cases/Create/CreateContactValidData.cs index 7f7aa36..9361821 100644 --- a/tests/TestData/Contacts/Create/CreateContactValidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Create/CreateContactValidData.cs @@ -1,7 +1,7 @@ +using TestData.Contacts.Requests; using WebApi.Contacts.Create; -using Xunit; -namespace TestData.Contacts.Create; +namespace IntegrationTests.Contacts.Cases.Create; public class CreateContactValidData : TheoryData { diff --git a/tests/TestData/Contacts/Get/GetContactsInvalidData.cs b/tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalidData.cs similarity index 84% rename from tests/TestData/Contacts/Get/GetContactsInvalidData.cs rename to tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalidData.cs index a1ad173..cebb9f7 100644 --- a/tests/TestData/Contacts/Get/GetContactsInvalidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalidData.cs @@ -1,9 +1,7 @@ -using Domain.Core.Pagination; -using Domain.Core.Primitives; +using TestData.Contacts.Requests; using WebApi.Contacts.Get; -using Xunit; -namespace TestData.Contacts.Get; +namespace IntegrationTests.Contacts.Cases.Get; public class GetContactsInvalidData : TheoryData { diff --git a/tests/TestData/Contacts/Get/GetContactsValidData.cs b/tests/IntegrationTests/Contacts/Cases/Get/GetContactsValidData.cs similarity index 83% rename from tests/TestData/Contacts/Get/GetContactsValidData.cs rename to tests/IntegrationTests/Contacts/Cases/Get/GetContactsValidData.cs index bef264d..47b385c 100644 --- a/tests/TestData/Contacts/Get/GetContactsValidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Get/GetContactsValidData.cs @@ -1,7 +1,7 @@ +using TestData.Contacts.Requests; using WebApi.Contacts.Get; -using Xunit; -namespace TestData.Contacts.Get; +namespace IntegrationTests.Contacts.Cases.Get; public class GetContactsValidData : TheoryData { diff --git a/tests/TestData/Contacts/Update/UpdateContactInvalidData.cs b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalidData.cs similarity index 88% rename from tests/TestData/Contacts/Update/UpdateContactInvalidData.cs rename to tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalidData.cs index c27b8fa..fbb6665 100644 --- a/tests/TestData/Contacts/Update/UpdateContactInvalidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalidData.cs @@ -1,9 +1,7 @@ -using Domain.Contacts; -using Domain.Core.Primitives; +using TestData.Contacts.Requests; using WebApi.Contacts.Update; -using Xunit; -namespace TestData.Contacts.Update; +namespace IntegrationTests.Contacts.Cases.Update; public class UpdateContactInvalidData : TheoryData { diff --git a/tests/TestData/Contacts/Update/UpdateContactValidData.cs b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValidData.cs similarity index 71% rename from tests/TestData/Contacts/Update/UpdateContactValidData.cs rename to tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValidData.cs index 3824f5a..0c239a1 100644 --- a/tests/TestData/Contacts/Update/UpdateContactValidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValidData.cs @@ -1,7 +1,7 @@ +using TestData.Contacts.Requests; using WebApi.Contacts.Update; -using Xunit; -namespace TestData.Contacts.Update; +namespace IntegrationTests.Contacts.Cases.Update; public class UpdateContactValidData : TheoryData { diff --git a/tests/TestData/Contacts/Update/UpdateContactWithNonExistingIdData.cs b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingIdData.cs similarity index 79% rename from tests/TestData/Contacts/Update/UpdateContactWithNonExistingIdData.cs rename to tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingIdData.cs index dd22cd8..da8af8f 100644 --- a/tests/TestData/Contacts/Update/UpdateContactWithNonExistingIdData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingIdData.cs @@ -1,9 +1,7 @@ -using Domain.Contacts; -using Domain.Core.Primitives; +using TestData.Contacts.Requests; using WebApi.Contacts.Update; -using Xunit; -namespace TestData.Contacts.Update; +namespace IntegrationTests.Contacts.Cases.Update; public class UpdateContactWithNonExistingIdData : TheoryData diff --git a/tests/IntegrationTests/Contacts/CreateContactTests.cs b/tests/IntegrationTests/Contacts/CreateContactTests.cs index 704b248..458ca1c 100644 --- a/tests/IntegrationTests/Contacts/CreateContactTests.cs +++ b/tests/IntegrationTests/Contacts/CreateContactTests.cs @@ -1,12 +1,8 @@ -using System.Net; -using System.Net.Http.Json; using Application.Contacts; -using Domain.Contacts; -using Domain.Core.Primitives; using Infrastructure.Database; +using IntegrationTests.Contacts.Cases.Create; using IntegrationTests.Core.Abstractions; using IntegrationTests.Core.Extensions; -using TestData.Contacts.Create; using WebApi.Contacts.Create; using WebApi.Core.Constants; @@ -36,7 +32,9 @@ CreateContactRequest request using PhoneForgeDbContext context = CreateDbContext(); - Contact? contact = context.Contacts.FirstOrDefault(c => c.Id == result.Id); + Contact? contact = await context.Contacts.FirstOrDefaultAsync(c => + c.Id == result.Id + ); Assert.NotNull(contact); } diff --git a/tests/IntegrationTests/Contacts/DeleteContactTests.cs b/tests/IntegrationTests/Contacts/DeleteContactTests.cs index 9e62d3a..72e3ae7 100644 --- a/tests/IntegrationTests/Contacts/DeleteContactTests.cs +++ b/tests/IntegrationTests/Contacts/DeleteContactTests.cs @@ -1,10 +1,6 @@ -using System.Net; -using Domain.Contacts; -using Domain.Core.Primitives; using Infrastructure.Database; using IntegrationTests.Core.Abstractions; using IntegrationTests.Core.Extensions; -using Microsoft.EntityFrameworkCore; namespace IntegrationTests.Contacts; diff --git a/tests/IntegrationTests/Contacts/GetContactByIdTests.cs b/tests/IntegrationTests/Contacts/GetContactByIdTests.cs index 3197f70..7a7cec9 100644 --- a/tests/IntegrationTests/Contacts/GetContactByIdTests.cs +++ b/tests/IntegrationTests/Contacts/GetContactByIdTests.cs @@ -1,8 +1,4 @@ -using System.Net; -using System.Net.Http.Json; using Application.Contacts; -using Domain.Contacts; -using Domain.Core.Primitives; using IntegrationTests.Core.Abstractions; using IntegrationTests.Core.Extensions; using WebApi.Core.Constants; diff --git a/tests/IntegrationTests/Contacts/GetContactsTests.cs b/tests/IntegrationTests/Contacts/GetContactsTests.cs index 7aaee45..f59f4d3 100644 --- a/tests/IntegrationTests/Contacts/GetContactsTests.cs +++ b/tests/IntegrationTests/Contacts/GetContactsTests.cs @@ -1,12 +1,8 @@ -using System.Net; -using System.Net.Http.Json; using Application.Contacts.Get; -using Domain.Contacts; -using Domain.Core.Primitives; using Infrastructure.Database; +using IntegrationTests.Contacts.Cases.Get; using IntegrationTests.Core.Abstractions; using IntegrationTests.Core.Extensions; -using TestData.Contacts.Get; using WebApi.Contacts.Get; using WebApi.Core.Constants; @@ -42,11 +38,11 @@ await HttpClient.GetFromJsonAsync( using PhoneForgeDbContext context = CreateDbContext(); - List contacts = context + List contacts = await context .Contacts.OrderBy(c => c.CreatedOnUtc) .Select(c => c.Id) .Take(10) - .ToList(); + .ToListAsync(); Assert.Equal(response.Items.Count, contacts.Count); } @@ -65,9 +61,9 @@ await HttpClient.GetFromJsonAsync( using PhoneForgeDbContext context = CreateDbContext(); - List contacts = context + List contacts = await context .Contacts.Where(x => x.Email.Value.Contains(email)) - .ToList(); + .ToListAsync(); Assert.Equal(contacts.Count, response.Items.Count); } diff --git a/tests/IntegrationTests/Contacts/UpdateContactTests.cs b/tests/IntegrationTests/Contacts/UpdateContactTests.cs index 3bdb0a7..40ddb61 100644 --- a/tests/IntegrationTests/Contacts/UpdateContactTests.cs +++ b/tests/IntegrationTests/Contacts/UpdateContactTests.cs @@ -1,11 +1,7 @@ -using System.Net; -using System.Net.Http.Json; -using Domain.Contacts; -using Domain.Core.Primitives; using Infrastructure.Database; +using IntegrationTests.Contacts.Cases.Update; using IntegrationTests.Core.Abstractions; using IntegrationTests.Core.Extensions; -using TestData.Contacts.Update; using WebApi.Contacts.Update; namespace IntegrationTests.Contacts; @@ -30,7 +26,9 @@ UpdateContactRequest request using PhoneForgeDbContext context = CreateDbContext(); - Contact? contact = context.Contacts.FirstOrDefault(c => c.Id == contactId); + Contact? contact = await context.Contacts.FirstOrDefaultAsync(c => + c.Id == contactId + ); Assert.NotNull(contact); Assert.Equal(request.FirstName, contact.FirstName); diff --git a/tests/IntegrationTests/Core/Abstractions/BaseIntegrationTest.cs b/tests/IntegrationTests/Core/Abstractions/BaseIntegrationTest.cs index 198e6a2..41d7993 100644 --- a/tests/IntegrationTests/Core/Abstractions/BaseIntegrationTest.cs +++ b/tests/IntegrationTests/Core/Abstractions/BaseIntegrationTest.cs @@ -1,6 +1,4 @@ using Infrastructure.Database; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using TestData.Seeding; namespace IntegrationTests.Core.Abstractions; diff --git a/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs b/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs index 80fe53b..1cf10da 100644 --- a/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs +++ b/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs @@ -1,12 +1,5 @@ using System.Diagnostics.CodeAnalysis; using Infrastructure.Database; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; [assembly: ExcludeFromCodeCoverage] diff --git a/tests/IntegrationTests/Core/Extensions/HttpResponseMessageExtensions.cs b/tests/IntegrationTests/Core/Extensions/HttpResponseMessageExtensions.cs index dcb7731..402348d 100644 --- a/tests/IntegrationTests/Core/Extensions/HttpResponseMessageExtensions.cs +++ b/tests/IntegrationTests/Core/Extensions/HttpResponseMessageExtensions.cs @@ -1,6 +1,3 @@ -using System.Net; -using System.Net.Http.Json; -using Domain.Core.Primitives; using IntegrationTests.Core.Contracts; namespace IntegrationTests.Core.Extensions; diff --git a/tests/IntegrationTests/GlobalUsings.cs b/tests/IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..f544c34 --- /dev/null +++ b/tests/IntegrationTests/GlobalUsings.cs @@ -0,0 +1,14 @@ +global using System.Diagnostics.CodeAnalysis; +global using System.Net; +global using System.Net.Http.Json; +global using Domain.Contacts; +global using Domain.Core.Pagination; +global using Domain.Core.Primitives; +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Mvc.Testing; +global using Microsoft.AspNetCore.TestHost; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using Xunit; diff --git a/tests/IntegrationTests/IntegrationTests.csproj b/tests/IntegrationTests/IntegrationTests.csproj index b23e840..a7a7ad4 100644 --- a/tests/IntegrationTests/IntegrationTests.csproj +++ b/tests/IntegrationTests/IntegrationTests.csproj @@ -13,10 +13,6 @@ - - - - diff --git a/tests/TestData/Contacts/ContactData.cs b/tests/TestData/Contacts/ContactData.cs index 4dbbc15..43bef24 100644 --- a/tests/TestData/Contacts/ContactData.cs +++ b/tests/TestData/Contacts/ContactData.cs @@ -1,9 +1,3 @@ -using Domain.Contacts; -using TestData.Emails; -using TestData.FirstNames; -using TestData.LastNames; -using TestData.PhoneNumbers; - namespace TestData.Contacts; public static class ContactData diff --git a/tests/TestData/Emails/EmailData.cs b/tests/TestData/Contacts/EmailData.cs similarity index 85% rename from tests/TestData/Emails/EmailData.cs rename to tests/TestData/Contacts/EmailData.cs index 163e405..3b7e7d4 100644 --- a/tests/TestData/Emails/EmailData.cs +++ b/tests/TestData/Contacts/EmailData.cs @@ -1,6 +1,4 @@ -using Domain.Contacts; - -namespace TestData.Emails; +namespace TestData.Contacts; public static class EmailData { diff --git a/tests/TestData/FirstNames/FirstNameData.cs b/tests/TestData/Contacts/FirstNameData.cs similarity index 83% rename from tests/TestData/FirstNames/FirstNameData.cs rename to tests/TestData/Contacts/FirstNameData.cs index 277659b..da00f9e 100644 --- a/tests/TestData/FirstNames/FirstNameData.cs +++ b/tests/TestData/Contacts/FirstNameData.cs @@ -1,6 +1,4 @@ -using Domain.Contacts; - -namespace TestData.FirstNames; +namespace TestData.Contacts; public static class FirstNameData { diff --git a/tests/TestData/LastNames/LastNameData.cs b/tests/TestData/Contacts/LastNameData.cs similarity index 83% rename from tests/TestData/LastNames/LastNameData.cs rename to tests/TestData/Contacts/LastNameData.cs index ef21c8b..0c3d7d3 100644 --- a/tests/TestData/LastNames/LastNameData.cs +++ b/tests/TestData/Contacts/LastNameData.cs @@ -1,6 +1,4 @@ -using Domain.Contacts; - -namespace TestData.LastNames; +namespace TestData.Contacts; public static class LastNameData { diff --git a/tests/TestData/PhoneNumbers/PhoneNumberData.cs b/tests/TestData/Contacts/PhoneNumberData.cs similarity index 89% rename from tests/TestData/PhoneNumbers/PhoneNumberData.cs rename to tests/TestData/Contacts/PhoneNumberData.cs index d5b7dd6..dedc19c 100644 --- a/tests/TestData/PhoneNumbers/PhoneNumberData.cs +++ b/tests/TestData/Contacts/PhoneNumberData.cs @@ -1,6 +1,4 @@ -using Domain.Contacts; - -namespace TestData.PhoneNumbers; +namespace TestData.Contacts; public static class PhoneNumberData { diff --git a/tests/TestData/Contacts/Create/CreateContactRequestData.cs b/tests/TestData/Contacts/Requests/CreateContactRequestData.cs similarity index 91% rename from tests/TestData/Contacts/Create/CreateContactRequestData.cs rename to tests/TestData/Contacts/Requests/CreateContactRequestData.cs index dea1d0a..c97a700 100644 --- a/tests/TestData/Contacts/Create/CreateContactRequestData.cs +++ b/tests/TestData/Contacts/Requests/CreateContactRequestData.cs @@ -1,10 +1,6 @@ -using TestData.Emails; -using TestData.FirstNames; -using TestData.LastNames; -using TestData.PhoneNumbers; using WebApi.Contacts.Create; -namespace TestData.Contacts.Create; +namespace TestData.Contacts.Requests; public static class CreateContactRequestData { diff --git a/tests/TestData/Contacts/Get/GetContactsRequestData.cs b/tests/TestData/Contacts/Requests/GetContactsRequestData.cs similarity index 92% rename from tests/TestData/Contacts/Get/GetContactsRequestData.cs rename to tests/TestData/Contacts/Requests/GetContactsRequestData.cs index 7e6848b..479274b 100644 --- a/tests/TestData/Contacts/Get/GetContactsRequestData.cs +++ b/tests/TestData/Contacts/Requests/GetContactsRequestData.cs @@ -1,7 +1,6 @@ -using Domain.Core.Pagination; using WebApi.Contacts.Get; -namespace TestData.Contacts.Get; +namespace TestData.Contacts.Requests; public static class GetContactsRequestData { diff --git a/tests/TestData/Contacts/Update/UpdateContactRequestData.cs b/tests/TestData/Contacts/Requests/UpdateContactRequestData.cs similarity index 91% rename from tests/TestData/Contacts/Update/UpdateContactRequestData.cs rename to tests/TestData/Contacts/Requests/UpdateContactRequestData.cs index 97e3247..df6d128 100644 --- a/tests/TestData/Contacts/Update/UpdateContactRequestData.cs +++ b/tests/TestData/Contacts/Requests/UpdateContactRequestData.cs @@ -1,10 +1,6 @@ -using TestData.Emails; -using TestData.FirstNames; -using TestData.LastNames; -using TestData.PhoneNumbers; using WebApi.Contacts.Update; -namespace TestData.Contacts.Update; +namespace TestData.Contacts.Requests; public static class UpdateContactRequestData { diff --git a/tests/TestData/GlobalUsings.cs b/tests/TestData/GlobalUsings.cs new file mode 100644 index 0000000..fb1b7f1 --- /dev/null +++ b/tests/TestData/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Domain.Contacts; +global using Domain.Core.Pagination; diff --git a/tests/TestData/Pagination/PageData.cs b/tests/TestData/Pagination/PageData.cs index 0c5b8c4..b342807 100644 --- a/tests/TestData/Pagination/PageData.cs +++ b/tests/TestData/Pagination/PageData.cs @@ -1,5 +1,3 @@ -using Domain.Core.Pagination; - namespace TestData.Pagination; public static class PageData diff --git a/tests/TestData/Pagination/PageSizeData.cs b/tests/TestData/Pagination/PageSizeData.cs index fc9f0ed..13c54cc 100644 --- a/tests/TestData/Pagination/PageSizeData.cs +++ b/tests/TestData/Pagination/PageSizeData.cs @@ -1,5 +1,3 @@ -using Domain.Core.Pagination; - namespace TestData.Pagination; public static class PageSizeData diff --git a/tests/TestData/Seeding/TestDataSeeder.cs b/tests/TestData/Seeding/TestDataSeeder.cs index 4d71523..48a44c9 100644 --- a/tests/TestData/Seeding/TestDataSeeder.cs +++ b/tests/TestData/Seeding/TestDataSeeder.cs @@ -1,5 +1,4 @@ using Bogus; -using Domain.Contacts; using Infrastructure.Database; namespace TestData.Seeding; diff --git a/tests/TestData/TestData.csproj b/tests/TestData/TestData.csproj index f8f080e..f92ba59 100644 --- a/tests/TestData/TestData.csproj +++ b/tests/TestData/TestData.csproj @@ -1,7 +1,6 @@  - From 57ee51d2c53d5d05a1e0bc152a1b1b7ec45aa497 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:24:42 +0100 Subject: [PATCH 31/46] test: add architecture tests (#56) * Add layer dependencies tests * add EntityTests * add handler, endpoint and validator tests --- Directory.Packages.props | 59 ++++++++++--------- PhoneForge.sln | 15 +++++ .../Application/CommandHandlerTests.cs | 39 ++++++++++++ .../Application/QueryHandlerTests.cs | 35 +++++++++++ .../ArchitectureTests.csproj | 12 ++++ tests/ArchitectureTests/BaseTest.cs | 15 +++++ tests/ArchitectureTests/Domain/EntityTests.cs | 48 +++++++++++++++ tests/ArchitectureTests/GlobalUsings.cs | 1 + .../Layers/ApplicationTests.cs | 30 ++++++++++ tests/ArchitectureTests/Layers/DomainTests.cs | 42 +++++++++++++ .../Layers/InfrastructureTests.cs | 18 ++++++ .../ArchitectureTests/WebApi/EndpointTests.cs | 49 +++++++++++++++ .../WebApi/ValidatorTests.cs | 21 +++++++ 13 files changed, 355 insertions(+), 29 deletions(-) create mode 100644 tests/ArchitectureTests/Application/CommandHandlerTests.cs create mode 100644 tests/ArchitectureTests/Application/QueryHandlerTests.cs create mode 100644 tests/ArchitectureTests/ArchitectureTests.csproj create mode 100644 tests/ArchitectureTests/BaseTest.cs create mode 100644 tests/ArchitectureTests/Domain/EntityTests.cs create mode 100644 tests/ArchitectureTests/GlobalUsings.cs create mode 100644 tests/ArchitectureTests/Layers/ApplicationTests.cs create mode 100644 tests/ArchitectureTests/Layers/DomainTests.cs create mode 100644 tests/ArchitectureTests/Layers/InfrastructureTests.cs create mode 100644 tests/ArchitectureTests/WebApi/EndpointTests.cs create mode 100644 tests/ArchitectureTests/WebApi/ValidatorTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e1b4b86..fb0dc40 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,30 +1,31 @@ - - true - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - + + true + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PhoneForge.sln b/PhoneForge.sln index 26f21bc..c5a5801 100644 --- a/PhoneForge.sln +++ b/PhoneForge.sln @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntegrationTests", "tests\I EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestData", "tests\TestData\TestData.csproj", "{9154254D-286D-4BD9-A409-235E62E6AB99}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArchitectureTests", "tests\ArchitectureTests\ArchitectureTests.csproj", "{0BDC0679-BA74-42EE-9E2F-FEAA0477301D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -139,6 +141,18 @@ Global {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|x64.Build.0 = Release|Any CPU {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|x86.ActiveCfg = Release|Any CPU {9154254D-286D-4BD9-A409-235E62E6AB99}.Release|x86.Build.0 = Release|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Debug|x64.ActiveCfg = Debug|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Debug|x64.Build.0 = Debug|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Debug|x86.ActiveCfg = Debug|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Debug|x86.Build.0 = Debug|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Release|Any CPU.Build.0 = Release|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Release|x64.ActiveCfg = Release|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Release|x64.Build.0 = Release|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Release|x86.ActiveCfg = Release|Any CPU + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -152,6 +166,7 @@ Global {F566C582-138F-7EAC-84B2-26BD8903E78D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {9154254D-286D-4BD9-A409-235E62E6AB99} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {0BDC0679-BA74-42EE-9E2F-FEAA0477301D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {646D0DF0-B048-4BFA-9672-6C5C78680E95} diff --git a/tests/ArchitectureTests/Application/CommandHandlerTests.cs b/tests/ArchitectureTests/Application/CommandHandlerTests.cs new file mode 100644 index 0000000..938fd58 --- /dev/null +++ b/tests/ArchitectureTests/Application/CommandHandlerTests.cs @@ -0,0 +1,39 @@ +using Application.Core.Abstractions.Messaging; +using NetArchTest.Rules; + +namespace ArchitectureTests.Application; + +public class CommandHandlerTests : BaseTest +{ + [Fact] + public void CommandHandler_Should_NotBePublic() + { + TestResult result = Types + .InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommandHandler<>)) + .Or() + .ImplementInterface(typeof(ICommandHandler<,>)) + .Should() + .NotBePublic() + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void CommandHandler_Should_BeSealed() + { + TestResult result = Types + .InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommandHandler<>)) + .Or() + .ImplementInterface(typeof(ICommandHandler<,>)) + .Should() + .BeSealed() + .GetResult(); + + Assert.True(result.IsSuccessful); + } +} diff --git a/tests/ArchitectureTests/Application/QueryHandlerTests.cs b/tests/ArchitectureTests/Application/QueryHandlerTests.cs new file mode 100644 index 0000000..1104a0c --- /dev/null +++ b/tests/ArchitectureTests/Application/QueryHandlerTests.cs @@ -0,0 +1,35 @@ +using Application.Core.Abstractions.Messaging; +using NetArchTest.Rules; + +namespace ArchitectureTests.Application; + +public class QueryHandlerTests : BaseTest +{ + [Fact] + public void QueryHandler_Should_NotBePublic() + { + TestResult result = Types + .InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .NotBePublic() + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void QueryHandler_Should_BeSealed() + { + TestResult result = Types + .InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .BeSealed() + .GetResult(); + + Assert.True(result.IsSuccessful); + } +} diff --git a/tests/ArchitectureTests/ArchitectureTests.csproj b/tests/ArchitectureTests/ArchitectureTests.csproj new file mode 100644 index 0000000..62b2dca --- /dev/null +++ b/tests/ArchitectureTests/ArchitectureTests.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/ArchitectureTests/BaseTest.cs b/tests/ArchitectureTests/BaseTest.cs new file mode 100644 index 0000000..985364e --- /dev/null +++ b/tests/ArchitectureTests/BaseTest.cs @@ -0,0 +1,15 @@ +using System.Reflection; +using Application.Core.Abstractions.Messaging; +using Domain.Contacts; +using Infrastructure.Database; + +namespace ArchitectureTests; + +public abstract class BaseTest +{ + protected static readonly Assembly DomainAssembly = typeof(Contact).Assembly; + protected static readonly Assembly ApplicationAssembly = typeof(ICommand).Assembly; + protected static readonly Assembly InfrastructureAssembly = + typeof(PhoneForgeDbContext).Assembly; + protected static readonly Assembly WebApiAssembly = typeof(Program).Assembly; +} diff --git a/tests/ArchitectureTests/Domain/EntityTests.cs b/tests/ArchitectureTests/Domain/EntityTests.cs new file mode 100644 index 0000000..e872569 --- /dev/null +++ b/tests/ArchitectureTests/Domain/EntityTests.cs @@ -0,0 +1,48 @@ +using System.Reflection; +using Domain.Core.Primitives; +using NetArchTest.Rules; + +namespace ArchitectureTests.Domain; + +public class EntityTests : BaseTest +{ + [Fact] + public void Entities_Should_BeSealed() + { + TestResult result = Types + .InAssembly(DomainAssembly) + .That() + .Inherit(typeof(Entity)) + .Should() + .BeSealed() + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void Entities_Should_HavePrivateParamaterlessConstructor() + { + IEnumerable entityTypes = Types + .InAssembly(DomainAssembly) + .That() + .Inherit(typeof(Entity)) + .GetTypes(); + + List failingTypes = []; + + foreach (Type entityType in entityTypes) + { + ConstructorInfo[] constructors = entityType.GetConstructors( + BindingFlags.NonPublic | BindingFlags.Instance + ); + + if (!constructors.Any(c => c.IsPrivate && c.GetParameters().Length == 0)) + { + failingTypes.Add(entityType); + } + } + + Assert.Empty(failingTypes); + } +} diff --git a/tests/ArchitectureTests/GlobalUsings.cs b/tests/ArchitectureTests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/ArchitectureTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/ArchitectureTests/Layers/ApplicationTests.cs b/tests/ArchitectureTests/Layers/ApplicationTests.cs new file mode 100644 index 0000000..cd08c0d --- /dev/null +++ b/tests/ArchitectureTests/Layers/ApplicationTests.cs @@ -0,0 +1,30 @@ +using NetArchTest.Rules; + +namespace ArchitectureTests.Layers; + +public class ApplicationTests : BaseTest +{ + [Fact] + public void Application_ShouldNotHaveDependencyOn_Infrastructure() + { + TestResult result = Types + .InAssembly(ApplicationAssembly) + .Should() + .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name) + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void Application_ShouldNotHaveDependencyOn_WebApi() + { + TestResult result = Types + .InAssembly(ApplicationAssembly) + .Should() + .NotHaveDependencyOn(WebApiAssembly.GetName().Name) + .GetResult(); + + Assert.True(result.IsSuccessful); + } +} diff --git a/tests/ArchitectureTests/Layers/DomainTests.cs b/tests/ArchitectureTests/Layers/DomainTests.cs new file mode 100644 index 0000000..37b845e --- /dev/null +++ b/tests/ArchitectureTests/Layers/DomainTests.cs @@ -0,0 +1,42 @@ +using NetArchTest.Rules; + +namespace ArchitectureTests.Layers; + +public class DomainTests : BaseTest +{ + [Fact] + public void Domain_Should_NotHaveDependencyOn_Application() + { + TestResult result = Types + .InAssembly(DomainAssembly) + .Should() + .NotHaveDependencyOn(ApplicationAssembly.GetName().Name) + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void Domain_Should_NotHaveDependencyOn_Infrastructure() + { + TestResult result = Types + .InAssembly(DomainAssembly) + .Should() + .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name) + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void Domain_Should_NotHaveDependencyOn_WebApi() + { + TestResult result = Types + .InAssembly(DomainAssembly) + .Should() + .NotHaveDependencyOn(WebApiAssembly.GetName().Name) + .GetResult(); + + Assert.True(result.IsSuccessful); + } +} diff --git a/tests/ArchitectureTests/Layers/InfrastructureTests.cs b/tests/ArchitectureTests/Layers/InfrastructureTests.cs new file mode 100644 index 0000000..2c27e77 --- /dev/null +++ b/tests/ArchitectureTests/Layers/InfrastructureTests.cs @@ -0,0 +1,18 @@ +using NetArchTest.Rules; + +namespace ArchitectureTests.Layers; + +public class InfrastructureTests : BaseTest +{ + [Fact] + public void Infrastructure_Should_NotHaveDependencyOn_WebApi() + { + TestResult result = Types + .InAssembly(InfrastructureAssembly) + .Should() + .NotHaveDependencyOn(WebApiAssembly.GetName().Name) + .GetResult(); + + Assert.True(result.IsSuccessful); + } +} diff --git a/tests/ArchitectureTests/WebApi/EndpointTests.cs b/tests/ArchitectureTests/WebApi/EndpointTests.cs new file mode 100644 index 0000000..b4ea78e --- /dev/null +++ b/tests/ArchitectureTests/WebApi/EndpointTests.cs @@ -0,0 +1,49 @@ +using NetArchTest.Rules; +using WebApi.Core; + +namespace ArchitectureTests.WebApi; + +public class EndpointTests : BaseTest +{ + [Fact] + public void Endpoint_ShouldHave_NameEndingWith_Endpoint() + { + TestResult result = Types + .InAssembly(WebApiAssembly) + .That() + .Inherit(typeof(IEndpoint)) + .Should() + .HaveNameEndingWith("Endpoint") + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void Endpoint_Should_NotBePublic() + { + TestResult result = Types + .InAssembly(WebApiAssembly) + .That() + .ImplementInterface(typeof(IEndpoint)) + .Should() + .NotBePublic() + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void Endpoint_Should_BeSealed() + { + TestResult result = Types + .InAssembly(WebApiAssembly) + .That() + .ImplementInterface(typeof(IEndpoint)) + .Should() + .BeSealed() + .GetResult(); + + Assert.True(result.IsSuccessful); + } +} diff --git a/tests/ArchitectureTests/WebApi/ValidatorTests.cs b/tests/ArchitectureTests/WebApi/ValidatorTests.cs new file mode 100644 index 0000000..5fe6ed4 --- /dev/null +++ b/tests/ArchitectureTests/WebApi/ValidatorTests.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using NetArchTest.Rules; + +namespace ArchitectureTests.WebApi; + +public class ValidatorTests : BaseTest +{ + [Fact] + public void Validator_ShouldHave_NameEndingWith_Validator() + { + TestResult result = Types + .InAssembly(WebApiAssembly) + .That() + .Inherit(typeof(AbstractValidator<>)) + .Should() + .HaveNameEndingWith("Validator") + .GetResult(); + + Assert.True(result.IsSuccessful); + } +} From 94eb2dcf66c73d9cb4174cfd2afc69f1867e8217 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:04:55 +0100 Subject: [PATCH 32/46] test: rename test cases and UnitTests project (#57) * remove unsued usings * rename CreateContact test cases * rename GetContacts test cases * rename UpdateContact test cases * rename Domain.UnitTests to UnitTests --- PhoneForge.sln | 2 +- ...reateContactInvalidData.cs => CreateContactInvalid.cs} | 4 ++-- .../{CreateContactValidData.cs => CreateContactValid.cs} | 4 ++-- .../{GetContactsInvalidData.cs => GetContactsInvalid.cs} | 4 ++-- .../Get/{GetContactsValidData.cs => GetContactsValid.cs} | 4 ++-- ...pdateContactInvalidData.cs => UpdateContactInvalid.cs} | 4 ++-- .../{UpdateContactValidData.cs => UpdateContactValid.cs} | 4 ++-- ...xistingIdData.cs => UpdateContactWithNonExistingId.cs} | 4 ++-- tests/IntegrationTests/Contacts/CreateContactTests.cs | 6 +++--- tests/IntegrationTests/Contacts/GetContactsTests.cs | 4 ++-- tests/IntegrationTests/Contacts/UpdateContactTests.cs | 8 ++++---- tests/{Domain.UnitTests => UnitTests}/Assembly.cs | 0 .../Contacts/Cases/ContactUpdate.cs | 0 .../Contacts/Cases/ContactValid.cs | 0 .../Contacts/Cases/Emails/EmailInvalid.cs | 0 .../Contacts/Cases/Emails/EmailValid.cs | 0 .../Contacts/Cases/FirstNames/FirstNameInvalid.cs | 0 .../Contacts/Cases/FirstNames/FirstNameValid.cs | 0 .../Contacts/Cases/LastNames/LastNameInvalid.cs | 0 .../Contacts/Cases/LastNames/LastNameValid.cs | 0 .../Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs | 0 .../Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs | 0 .../Contacts/ContactTests.cs | 0 .../Contacts/EmailTests.cs | 0 .../Contacts/FirstNameTests.cs | 0 .../Contacts/LastNameTests.cs | 0 .../Contacts/PhoneNumberTests.cs | 0 tests/{Domain.UnitTests => UnitTests}/GlobalUsings.cs | 0 .../Pagination/Cases/PageInvalid.cs | 2 -- .../Pagination/Cases/PageSizeInvalid.cs | 2 -- .../Pagination/Cases/PageSizeValid.cs | 0 .../Pagination/Cases/PageValid.cs | 2 -- .../Pagination/Cases/PagedListValid.cs | 1 - .../Pagination/PageSizeTests.cs | 0 .../Pagination/PageTests.cs | 2 +- .../Pagination/PagedListTests.cs | 0 tests/{Domain.UnitTests => UnitTests}/UnitTests.csproj | 0 37 files changed, 25 insertions(+), 32 deletions(-) rename tests/IntegrationTests/Contacts/Cases/Create/{CreateContactInvalidData.cs => CreateContactInvalid.cs} (86%) rename tests/IntegrationTests/Contacts/Cases/Create/{CreateContactValidData.cs => CreateContactValid.cs} (64%) rename tests/IntegrationTests/Contacts/Cases/Get/{GetContactsInvalidData.cs => GetContactsInvalid.cs} (83%) rename tests/IntegrationTests/Contacts/Cases/Get/{GetContactsValidData.cs => GetContactsValid.cs} (75%) rename tests/IntegrationTests/Contacts/Cases/Update/{UpdateContactInvalidData.cs => UpdateContactInvalid.cs} (86%) rename tests/IntegrationTests/Contacts/Cases/Update/{UpdateContactValidData.cs => UpdateContactValid.cs} (64%) rename tests/IntegrationTests/Contacts/Cases/Update/{UpdateContactWithNonExistingIdData.cs => UpdateContactWithNonExistingId.cs} (79%) rename tests/{Domain.UnitTests => UnitTests}/Assembly.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/ContactUpdate.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/ContactValid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/Emails/EmailInvalid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/Emails/EmailValid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/FirstNames/FirstNameInvalid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/FirstNames/FirstNameValid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/LastNames/LastNameInvalid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/LastNames/LastNameValid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/ContactTests.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/EmailTests.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/FirstNameTests.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/LastNameTests.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Contacts/PhoneNumberTests.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/GlobalUsings.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Pagination/Cases/PageInvalid.cs (78%) rename tests/{Domain.UnitTests => UnitTests}/Pagination/Cases/PageSizeInvalid.cs (85%) rename tests/{Domain.UnitTests => UnitTests}/Pagination/Cases/PageSizeValid.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Pagination/Cases/PageValid.cs (85%) rename tests/{Domain.UnitTests => UnitTests}/Pagination/Cases/PagedListValid.cs (95%) rename tests/{Domain.UnitTests => UnitTests}/Pagination/PageSizeTests.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/Pagination/PageTests.cs (86%) rename tests/{Domain.UnitTests => UnitTests}/Pagination/PagedListTests.cs (100%) rename tests/{Domain.UnitTests => UnitTests}/UnitTests.csproj (100%) diff --git a/PhoneForge.sln b/PhoneForge.sln index c5a5801..197cd5b 100644 --- a/PhoneForge.sln +++ b/PhoneForge.sln @@ -19,7 +19,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi", "backend\WebApi\We EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Console", "frontend\Console\Console.csproj", "{15D2C271-E237-D10C-AFFC-2FB01BAF09C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\Domain.UnitTests\UnitTests.csproj", "{F566C582-138F-7EAC-84B2-26BD8903E78D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{F566C582-138F-7EAC-84B2-26BD8903E78D}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{089100B1-113F-4E66-888A-E83F3999EAFD}" ProjectSection(SolutionItems) = preProject diff --git a/tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalidData.cs b/tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalid.cs similarity index 86% rename from tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalidData.cs rename to tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalid.cs index 6870851..2122463 100644 --- a/tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Create/CreateContactInvalid.cs @@ -3,9 +3,9 @@ namespace IntegrationTests.Contacts.Cases.Create; -public class CreateContactInvalidData : TheoryData +public class CreateContactInvalid : TheoryData { - public CreateContactInvalidData() + public CreateContactInvalid() { Add( CreateContactRequestData.CreateRequestWithInvalidFirstName(), diff --git a/tests/IntegrationTests/Contacts/Cases/Create/CreateContactValidData.cs b/tests/IntegrationTests/Contacts/Cases/Create/CreateContactValid.cs similarity index 64% rename from tests/IntegrationTests/Contacts/Cases/Create/CreateContactValidData.cs rename to tests/IntegrationTests/Contacts/Cases/Create/CreateContactValid.cs index 9361821..ff689c4 100644 --- a/tests/IntegrationTests/Contacts/Cases/Create/CreateContactValidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Create/CreateContactValid.cs @@ -3,9 +3,9 @@ namespace IntegrationTests.Contacts.Cases.Create; -public class CreateContactValidData : TheoryData +public class CreateContactValid : TheoryData { - public CreateContactValidData() + public CreateContactValid() { Add(CreateContactRequestData.CreateValidRequest()); } diff --git a/tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalidData.cs b/tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalid.cs similarity index 83% rename from tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalidData.cs rename to tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalid.cs index cebb9f7..62dd5e9 100644 --- a/tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Get/GetContactsInvalid.cs @@ -3,9 +3,9 @@ namespace IntegrationTests.Contacts.Cases.Get; -public class GetContactsInvalidData : TheoryData +public class GetContactsInvalid : TheoryData { - public GetContactsInvalidData() + public GetContactsInvalid() { Add( GetContactsRequestData.CreateRequestWithInvalidPage(), diff --git a/tests/IntegrationTests/Contacts/Cases/Get/GetContactsValidData.cs b/tests/IntegrationTests/Contacts/Cases/Get/GetContactsValid.cs similarity index 75% rename from tests/IntegrationTests/Contacts/Cases/Get/GetContactsValidData.cs rename to tests/IntegrationTests/Contacts/Cases/Get/GetContactsValid.cs index 47b385c..560dd05 100644 --- a/tests/IntegrationTests/Contacts/Cases/Get/GetContactsValidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Get/GetContactsValid.cs @@ -3,9 +3,9 @@ namespace IntegrationTests.Contacts.Cases.Get; -public class GetContactsValidData : TheoryData +public class GetContactsValid : TheoryData { - public GetContactsValidData() + public GetContactsValid() { Add(GetContactsRequestData.CreateDefaultValidRequest(), 20, 2, false, true); Add( diff --git a/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalidData.cs b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalid.cs similarity index 86% rename from tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalidData.cs rename to tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalid.cs index fbb6665..9da8351 100644 --- a/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactInvalid.cs @@ -3,9 +3,9 @@ namespace IntegrationTests.Contacts.Cases.Update; -public class UpdateContactInvalidData : TheoryData +public class UpdateContactInvalid : TheoryData { - public UpdateContactInvalidData() + public UpdateContactInvalid() { Add( UpdateContactRequestData.CreateRequestWithInvalidFirstName(), diff --git a/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValidData.cs b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValid.cs similarity index 64% rename from tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValidData.cs rename to tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValid.cs index 0c239a1..556580a 100644 --- a/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValidData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactValid.cs @@ -3,9 +3,9 @@ namespace IntegrationTests.Contacts.Cases.Update; -public class UpdateContactValidData : TheoryData +public class UpdateContactValid : TheoryData { - public UpdateContactValidData() + public UpdateContactValid() { Add(UpdateContactRequestData.CreateValidRequest()); } diff --git a/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingIdData.cs b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingId.cs similarity index 79% rename from tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingIdData.cs rename to tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingId.cs index da8af8f..9ee8e22 100644 --- a/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingIdData.cs +++ b/tests/IntegrationTests/Contacts/Cases/Update/UpdateContactWithNonExistingId.cs @@ -3,12 +3,12 @@ namespace IntegrationTests.Contacts.Cases.Update; -public class UpdateContactWithNonExistingIdData +public class UpdateContactWithNonExistingId : TheoryData { private readonly Guid _id = Guid.NewGuid(); - public UpdateContactWithNonExistingIdData() + public UpdateContactWithNonExistingId() { Add( UpdateContactRequestData.CreateValidRequest(), diff --git a/tests/IntegrationTests/Contacts/CreateContactTests.cs b/tests/IntegrationTests/Contacts/CreateContactTests.cs index 458ca1c..734d0eb 100644 --- a/tests/IntegrationTests/Contacts/CreateContactTests.cs +++ b/tests/IntegrationTests/Contacts/CreateContactTests.cs @@ -14,7 +14,7 @@ public class CreateContactTests(IntegrationTestWebAppFactory factory) private const string CreateContactRoute = $"api/v1/{Routes.Contacts.Create}"; [Theory] - [ClassData(typeof(CreateContactValidData))] + [ClassData(typeof(CreateContactValid))] public async Task Should_ReturnCreated_WhenContactIsCreated( CreateContactRequest request ) @@ -39,7 +39,7 @@ CreateContactRequest request } [Theory] - [ClassData(typeof(CreateContactInvalidData))] + [ClassData(typeof(CreateContactInvalid))] public async Task Should_ReturnBadRequest_WhenRequestIsInvalid( CreateContactRequest request, Error expected @@ -54,7 +54,7 @@ Error expected } [Theory] - [ClassData(typeof(CreateContactValidData))] + [ClassData(typeof(CreateContactValid))] public async Task Should_ReturnConflict_WhenEmailIsNotUnique( CreateContactRequest request ) diff --git a/tests/IntegrationTests/Contacts/GetContactsTests.cs b/tests/IntegrationTests/Contacts/GetContactsTests.cs index f59f4d3..821fcc7 100644 --- a/tests/IntegrationTests/Contacts/GetContactsTests.cs +++ b/tests/IntegrationTests/Contacts/GetContactsTests.cs @@ -14,7 +14,7 @@ public class GetContactsTests(IntegrationTestWebAppFactory factory) private const string GetContactsRoute = $"api/v1/{Routes.Contacts.Get}"; [Theory] - [ClassData(typeof(GetContactsValidData))] + [ClassData(typeof(GetContactsValid))] public async Task Should_ReturnCorrectPages( GetContactsRequest request, int totalCount, @@ -69,7 +69,7 @@ await HttpClient.GetFromJsonAsync( } [Theory] - [ClassData(typeof(GetContactsInvalidData))] + [ClassData(typeof(GetContactsInvalid))] public async Task Should_ReturnError_WhenPagingDataIsInvalid( GetContactsRequest request, Error expected diff --git a/tests/IntegrationTests/Contacts/UpdateContactTests.cs b/tests/IntegrationTests/Contacts/UpdateContactTests.cs index 40ddb61..1509757 100644 --- a/tests/IntegrationTests/Contacts/UpdateContactTests.cs +++ b/tests/IntegrationTests/Contacts/UpdateContactTests.cs @@ -10,7 +10,7 @@ public class UpdateContactTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) { [Theory] - [ClassData(typeof(UpdateContactValidData))] + [ClassData(typeof(UpdateContactValid))] public async Task Should_ReturnNoContent_WhenContactWasUpdated( UpdateContactRequest request ) @@ -39,7 +39,7 @@ UpdateContactRequest request } [Theory] - [ClassData(typeof(UpdateContactWithNonExistingIdData))] + [ClassData(typeof(UpdateContactWithNonExistingId))] public async Task Should_ReturnNotFound_WhenContactDoesNotExist( UpdateContactRequest request, Guid contactId, @@ -55,7 +55,7 @@ Error expected } [Theory] - [ClassData(typeof(UpdateContactInvalidData))] + [ClassData(typeof(UpdateContactInvalid))] public async Task Should_ReturnBadRequest_WhenRequestIsInvalid( UpdateContactRequest request, Error expected @@ -72,7 +72,7 @@ Error expected } [Theory] - [ClassData(typeof(UpdateContactValidData))] + [ClassData(typeof(UpdateContactValid))] public async Task Should_ReturnConflict_WhenEmailIsNotUnique( UpdateContactRequest request ) diff --git a/tests/Domain.UnitTests/Assembly.cs b/tests/UnitTests/Assembly.cs similarity index 100% rename from tests/Domain.UnitTests/Assembly.cs rename to tests/UnitTests/Assembly.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/ContactUpdate.cs b/tests/UnitTests/Contacts/Cases/ContactUpdate.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/ContactUpdate.cs rename to tests/UnitTests/Contacts/Cases/ContactUpdate.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/ContactValid.cs b/tests/UnitTests/Contacts/Cases/ContactValid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/ContactValid.cs rename to tests/UnitTests/Contacts/Cases/ContactValid.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/Emails/EmailInvalid.cs b/tests/UnitTests/Contacts/Cases/Emails/EmailInvalid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/Emails/EmailInvalid.cs rename to tests/UnitTests/Contacts/Cases/Emails/EmailInvalid.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/Emails/EmailValid.cs b/tests/UnitTests/Contacts/Cases/Emails/EmailValid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/Emails/EmailValid.cs rename to tests/UnitTests/Contacts/Cases/Emails/EmailValid.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameInvalid.cs b/tests/UnitTests/Contacts/Cases/FirstNames/FirstNameInvalid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameInvalid.cs rename to tests/UnitTests/Contacts/Cases/FirstNames/FirstNameInvalid.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameValid.cs b/tests/UnitTests/Contacts/Cases/FirstNames/FirstNameValid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/FirstNames/FirstNameValid.cs rename to tests/UnitTests/Contacts/Cases/FirstNames/FirstNameValid.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameInvalid.cs b/tests/UnitTests/Contacts/Cases/LastNames/LastNameInvalid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameInvalid.cs rename to tests/UnitTests/Contacts/Cases/LastNames/LastNameInvalid.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameValid.cs b/tests/UnitTests/Contacts/Cases/LastNames/LastNameValid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/LastNames/LastNameValid.cs rename to tests/UnitTests/Contacts/Cases/LastNames/LastNameValid.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs b/tests/UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs rename to tests/UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberInvalid.cs diff --git a/tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs b/tests/UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs rename to tests/UnitTests/Contacts/Cases/PhoneNumbers/PhoneNumberValid.cs diff --git a/tests/Domain.UnitTests/Contacts/ContactTests.cs b/tests/UnitTests/Contacts/ContactTests.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/ContactTests.cs rename to tests/UnitTests/Contacts/ContactTests.cs diff --git a/tests/Domain.UnitTests/Contacts/EmailTests.cs b/tests/UnitTests/Contacts/EmailTests.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/EmailTests.cs rename to tests/UnitTests/Contacts/EmailTests.cs diff --git a/tests/Domain.UnitTests/Contacts/FirstNameTests.cs b/tests/UnitTests/Contacts/FirstNameTests.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/FirstNameTests.cs rename to tests/UnitTests/Contacts/FirstNameTests.cs diff --git a/tests/Domain.UnitTests/Contacts/LastNameTests.cs b/tests/UnitTests/Contacts/LastNameTests.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/LastNameTests.cs rename to tests/UnitTests/Contacts/LastNameTests.cs diff --git a/tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs b/tests/UnitTests/Contacts/PhoneNumberTests.cs similarity index 100% rename from tests/Domain.UnitTests/Contacts/PhoneNumberTests.cs rename to tests/UnitTests/Contacts/PhoneNumberTests.cs diff --git a/tests/Domain.UnitTests/GlobalUsings.cs b/tests/UnitTests/GlobalUsings.cs similarity index 100% rename from tests/Domain.UnitTests/GlobalUsings.cs rename to tests/UnitTests/GlobalUsings.cs diff --git a/tests/Domain.UnitTests/Pagination/Cases/PageInvalid.cs b/tests/UnitTests/Pagination/Cases/PageInvalid.cs similarity index 78% rename from tests/Domain.UnitTests/Pagination/Cases/PageInvalid.cs rename to tests/UnitTests/Pagination/Cases/PageInvalid.cs index 5bf0131..e40bc92 100644 --- a/tests/Domain.UnitTests/Pagination/Cases/PageInvalid.cs +++ b/tests/UnitTests/Pagination/Cases/PageInvalid.cs @@ -1,5 +1,3 @@ -using Domain.Core.Pagination; -using Domain.Core.Primitives; using TestData.Pagination; namespace UnitTests.Pagination.Cases; diff --git a/tests/Domain.UnitTests/Pagination/Cases/PageSizeInvalid.cs b/tests/UnitTests/Pagination/Cases/PageSizeInvalid.cs similarity index 85% rename from tests/Domain.UnitTests/Pagination/Cases/PageSizeInvalid.cs rename to tests/UnitTests/Pagination/Cases/PageSizeInvalid.cs index 351d800..7fbfee5 100644 --- a/tests/Domain.UnitTests/Pagination/Cases/PageSizeInvalid.cs +++ b/tests/UnitTests/Pagination/Cases/PageSizeInvalid.cs @@ -1,5 +1,3 @@ -using Domain.Core.Pagination; -using Domain.Core.Primitives; using TestData.Pagination; namespace UnitTests.Pagination.Cases; diff --git a/tests/Domain.UnitTests/Pagination/Cases/PageSizeValid.cs b/tests/UnitTests/Pagination/Cases/PageSizeValid.cs similarity index 100% rename from tests/Domain.UnitTests/Pagination/Cases/PageSizeValid.cs rename to tests/UnitTests/Pagination/Cases/PageSizeValid.cs diff --git a/tests/Domain.UnitTests/Pagination/Cases/PageValid.cs b/tests/UnitTests/Pagination/Cases/PageValid.cs similarity index 85% rename from tests/Domain.UnitTests/Pagination/Cases/PageValid.cs rename to tests/UnitTests/Pagination/Cases/PageValid.cs index e9e24aa..441b64a 100644 --- a/tests/Domain.UnitTests/Pagination/Cases/PageValid.cs +++ b/tests/UnitTests/Pagination/Cases/PageValid.cs @@ -1,5 +1,3 @@ -using Domain.Core.Pagination; - namespace UnitTests.Pagination.Cases; public class PageValid : TheoryData diff --git a/tests/Domain.UnitTests/Pagination/Cases/PagedListValid.cs b/tests/UnitTests/Pagination/Cases/PagedListValid.cs similarity index 95% rename from tests/Domain.UnitTests/Pagination/Cases/PagedListValid.cs rename to tests/UnitTests/Pagination/Cases/PagedListValid.cs index a95e847..070d19a 100644 --- a/tests/Domain.UnitTests/Pagination/Cases/PagedListValid.cs +++ b/tests/UnitTests/Pagination/Cases/PagedListValid.cs @@ -1,4 +1,3 @@ -using Domain.Core.Pagination; using TestData.Pagination; namespace UnitTests.Pagination.Cases; diff --git a/tests/Domain.UnitTests/Pagination/PageSizeTests.cs b/tests/UnitTests/Pagination/PageSizeTests.cs similarity index 100% rename from tests/Domain.UnitTests/Pagination/PageSizeTests.cs rename to tests/UnitTests/Pagination/PageSizeTests.cs diff --git a/tests/Domain.UnitTests/Pagination/PageTests.cs b/tests/UnitTests/Pagination/PageTests.cs similarity index 86% rename from tests/Domain.UnitTests/Pagination/PageTests.cs rename to tests/UnitTests/Pagination/PageTests.cs index 005d26e..c9c5014 100644 --- a/tests/Domain.UnitTests/Pagination/PageTests.cs +++ b/tests/UnitTests/Pagination/PageTests.cs @@ -16,7 +16,7 @@ public void Create_Should_ReturnSuccess_WithValidInput(int input) [Theory] [ClassData(typeof(PageInvalid))] - public void Create_Should_ReturnError_WithTooLowInput(int input, Error expected) + public void Create_Should_ReturnError_WithInvalidInput(int input, Error expected) { Result result = Page.Create(input); diff --git a/tests/Domain.UnitTests/Pagination/PagedListTests.cs b/tests/UnitTests/Pagination/PagedListTests.cs similarity index 100% rename from tests/Domain.UnitTests/Pagination/PagedListTests.cs rename to tests/UnitTests/Pagination/PagedListTests.cs diff --git a/tests/Domain.UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj similarity index 100% rename from tests/Domain.UnitTests/UnitTests.csproj rename to tests/UnitTests/UnitTests.csproj From ce9e91b75042ab11ea015951d7a93ae7186fc604 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:33:13 +0100 Subject: [PATCH 33/46] refactor: add "Handler" postfix to use cases (#58) * add Handler postifx to use case handlers * add tests for handler names --- ...eateContact.cs => CreateContactHandler.cs} | 2 +- ...leteContact.cs => DeleteContactHandler.cs} | 2 +- .../{GetContacts.cs => GetContactsHandler.cs} | 2 +- ...ontactById.cs => GetContactByIdHandler.cs} | 2 +- ...dateContact.cs => UpdateContactHandler.cs} | 2 +- .../Application/CommandHandlerTests.cs | 20 +++++++++++++++++++ .../Application/QueryHandlerTests.cs | 16 +++++++++++++++ 7 files changed, 41 insertions(+), 5 deletions(-) rename backend/Application/Contacts/Create/{CreateContact.cs => CreateContactHandler.cs} (96%) rename backend/Application/Contacts/Delete/{DeleteContact.cs => DeleteContactHandler.cs} (92%) rename backend/Application/Contacts/Get/{GetContacts.cs => GetContactsHandler.cs} (98%) rename backend/Application/Contacts/GetById/{GetContactById.cs => GetContactByIdHandler.cs} (94%) rename backend/Application/Contacts/Update/{UpdateContact.cs => UpdateContactHandler.cs} (96%) diff --git a/backend/Application/Contacts/Create/CreateContact.cs b/backend/Application/Contacts/Create/CreateContactHandler.cs similarity index 96% rename from backend/Application/Contacts/Create/CreateContact.cs rename to backend/Application/Contacts/Create/CreateContactHandler.cs index f8d7c72..a9f49f5 100644 --- a/backend/Application/Contacts/Create/CreateContact.cs +++ b/backend/Application/Contacts/Create/CreateContactHandler.cs @@ -6,7 +6,7 @@ namespace Application.Contacts.Create; -internal sealed class CreateContact(IDbContext context) +internal sealed class CreateContactHandler(IDbContext context) : ICommandHandler { public async Task> Handle( diff --git a/backend/Application/Contacts/Delete/DeleteContact.cs b/backend/Application/Contacts/Delete/DeleteContactHandler.cs similarity index 92% rename from backend/Application/Contacts/Delete/DeleteContact.cs rename to backend/Application/Contacts/Delete/DeleteContactHandler.cs index a0b43ec..32f1bed 100644 --- a/backend/Application/Contacts/Delete/DeleteContact.cs +++ b/backend/Application/Contacts/Delete/DeleteContactHandler.cs @@ -6,7 +6,7 @@ namespace Application.Contacts.Delete; -internal sealed class DeleteContact(IDbContext context) +internal sealed class DeleteContactHandler(IDbContext context) : ICommandHandler { public async Task Handle( diff --git a/backend/Application/Contacts/Get/GetContacts.cs b/backend/Application/Contacts/Get/GetContactsHandler.cs similarity index 98% rename from backend/Application/Contacts/Get/GetContacts.cs rename to backend/Application/Contacts/Get/GetContactsHandler.cs index 9f3c654..b8cd718 100644 --- a/backend/Application/Contacts/Get/GetContacts.cs +++ b/backend/Application/Contacts/Get/GetContactsHandler.cs @@ -9,7 +9,7 @@ namespace Application.Contacts.Get; -internal sealed class GetContacts(IDbContext context) +internal sealed class GetContactsHandler(IDbContext context) : IQueryHandler { public async Task> Handle( diff --git a/backend/Application/Contacts/GetById/GetContactById.cs b/backend/Application/Contacts/GetById/GetContactByIdHandler.cs similarity index 94% rename from backend/Application/Contacts/GetById/GetContactById.cs rename to backend/Application/Contacts/GetById/GetContactByIdHandler.cs index b6aa844..5001b9d 100644 --- a/backend/Application/Contacts/GetById/GetContactById.cs +++ b/backend/Application/Contacts/GetById/GetContactByIdHandler.cs @@ -6,7 +6,7 @@ namespace Application.Contacts.GetById; -internal sealed class GetContactById(IDbContext context) +internal sealed class GetContactByIdHandler(IDbContext context) : IQueryHandler { public async Task> Handle( diff --git a/backend/Application/Contacts/Update/UpdateContact.cs b/backend/Application/Contacts/Update/UpdateContactHandler.cs similarity index 96% rename from backend/Application/Contacts/Update/UpdateContact.cs rename to backend/Application/Contacts/Update/UpdateContactHandler.cs index 8baac83..b0bfbbe 100644 --- a/backend/Application/Contacts/Update/UpdateContact.cs +++ b/backend/Application/Contacts/Update/UpdateContactHandler.cs @@ -6,7 +6,7 @@ namespace Application.Contacts.Update; -internal sealed class UpdateContact(IDbContext context) +internal sealed class UpdateContactHandler(IDbContext context) : ICommandHandler { public async Task Handle( diff --git a/tests/ArchitectureTests/Application/CommandHandlerTests.cs b/tests/ArchitectureTests/Application/CommandHandlerTests.cs index 938fd58..e102717 100644 --- a/tests/ArchitectureTests/Application/CommandHandlerTests.cs +++ b/tests/ArchitectureTests/Application/CommandHandlerTests.cs @@ -36,4 +36,24 @@ public void CommandHandler_Should_BeSealed() Assert.True(result.IsSuccessful); } + + [Fact] + public void CommandHandler_ShouldHave_NameEndingWith_Handler() + { + TestResult result = Types + .InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommandHandler<>)) + .And() + .AreNotGeneric() + .Or() + .ImplementInterface(typeof(ICommandHandler<,>)) + .And() + .AreNotGeneric() + .Should() + .HaveNameEndingWith("Handler") + .GetResult(); + + Assert.True(result.IsSuccessful); + } } diff --git a/tests/ArchitectureTests/Application/QueryHandlerTests.cs b/tests/ArchitectureTests/Application/QueryHandlerTests.cs index 1104a0c..bf7e380 100644 --- a/tests/ArchitectureTests/Application/QueryHandlerTests.cs +++ b/tests/ArchitectureTests/Application/QueryHandlerTests.cs @@ -32,4 +32,20 @@ public void QueryHandler_Should_BeSealed() Assert.True(result.IsSuccessful); } + + [Fact] + public void QueryHandler_ShouldHave_NameEndingWith_Handler() + { + TestResult result = Types + .InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .And() + .AreNotGeneric() + .Should() + .HaveNameEndingWith("Handler") + .GetResult(); + + Assert.True(result.IsSuccessful); + } } From 1e974b39b69c61f6e1aaf1118426005557f0cfa6 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:25:51 +0100 Subject: [PATCH 34/46] refactor: change Fluent Validation validators access modifier to internal (#59) * change validator access modifiers to internal * add Validator tests --- .../Create/CreateContactRequestValidator.cs | 3 +- .../Update/UpdateContactRequestValidator.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 5 +++- .../WebApi/ValidatorTests.cs | 28 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/backend/WebApi/Contacts/Create/CreateContactRequestValidator.cs b/backend/WebApi/Contacts/Create/CreateContactRequestValidator.cs index ec9c1fc..0a441a9 100644 --- a/backend/WebApi/Contacts/Create/CreateContactRequestValidator.cs +++ b/backend/WebApi/Contacts/Create/CreateContactRequestValidator.cs @@ -6,7 +6,8 @@ namespace WebApi.Contacts.Create; /// /// Validates instances. /// -public class CreateContactRequestValidator : AbstractValidator +internal sealed class CreateContactRequestValidator + : AbstractValidator { /// /// Defines the validation rules for a . diff --git a/backend/WebApi/Contacts/Update/UpdateContactRequestValidator.cs b/backend/WebApi/Contacts/Update/UpdateContactRequestValidator.cs index 3a5fb9d..8137fc4 100644 --- a/backend/WebApi/Contacts/Update/UpdateContactRequestValidator.cs +++ b/backend/WebApi/Contacts/Update/UpdateContactRequestValidator.cs @@ -6,7 +6,8 @@ namespace WebApi.Contacts.Update; /// /// Validates the instances. /// -public class UpdateContactRequestValidator : AbstractValidator +internal sealed class UpdateContactRequestValidator + : AbstractValidator { /// /// Defines the validation rules for diff --git a/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs b/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs index 93869a8..2442d35 100644 --- a/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs +++ b/backend/WebApi/Core/Extensions/ServiceCollectionExtensions.cs @@ -83,6 +83,9 @@ private static void AddCustomProblemDetails(this IServiceCollection services) private static void AddFluentValidation(this IServiceCollection services) { - services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + services.AddValidatorsFromAssembly( + Assembly.GetExecutingAssembly(), + includeInternalTypes: true + ); } } diff --git a/tests/ArchitectureTests/WebApi/ValidatorTests.cs b/tests/ArchitectureTests/WebApi/ValidatorTests.cs index 5fe6ed4..79f08c1 100644 --- a/tests/ArchitectureTests/WebApi/ValidatorTests.cs +++ b/tests/ArchitectureTests/WebApi/ValidatorTests.cs @@ -18,4 +18,32 @@ public void Validator_ShouldHave_NameEndingWith_Validator() Assert.True(result.IsSuccessful); } + + [Fact] + public void Validator_Should_NotBePublic() + { + TestResult result = Types + .InAssembly(WebApiAssembly) + .That() + .Inherit(typeof(AbstractValidator<>)) + .Should() + .NotBePublic() + .GetResult(); + + Assert.True(result.IsSuccessful); + } + + [Fact] + public void Validator_Should_BeSealed() + { + TestResult result = Types + .InAssembly(WebApiAssembly) + .That() + .Inherit(typeof(AbstractValidator<>)) + .Should() + .BeSealed() + .GetResult(); + + Assert.True(result.IsSuccessful); + } } From 6f56af6d0750fec385358c3968743119fc1e50be Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:12:11 +0100 Subject: [PATCH 35/46] build: add build verification GitHub Actions workflow (#60) * add build validation Github Actions wokflow * apply formatting * update .NET version * update .NET version to 10.0 * update .NET version to 10.0.x * update Ubuntu version * rename build configuration file * fix typo in build configuration comments * fix typo in build configuration comment * remove build configuration comment * delete build configuration file * add build configuration file * add README.md with build status badge * remove push event trigger --- .github/workflows/build-validation.yml | 31 ++++++++++ ...ctory.Build.Props => Directory.Build.props | 1 - Directory.Packages.props | 60 +++++++++---------- PhoneForge.sln | 2 +- README.md | 3 + ...51124203322_RefactorToComplexProperties.cs | 24 +++++--- .../20251124204711_FixColumnNames.cs | 24 +++++--- 7 files changed, 97 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/build-validation.yml rename Directory.Build.Props => Directory.Build.props (95%) create mode 100644 README.md diff --git a/.github/workflows/build-validation.yml b/.github/workflows/build-validation.yml new file mode 100644 index 0000000..c3dad0a --- /dev/null +++ b/.github/workflows/build-validation.yml @@ -0,0 +1,31 @@ +name: build + +on: + pull_request: + branches: + - main + - develop + paths: + - "**.cs" + - "**.csproj" + +env: + DOTNET_VERSION: "10.0.x" # The .NET SDK version to use + +jobs: + build: + name: build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore diff --git a/Directory.Build.Props b/Directory.Build.props similarity index 95% rename from Directory.Build.Props rename to Directory.Build.props index 5163a68..025a729 100644 --- a/Directory.Build.Props +++ b/Directory.Build.props @@ -3,7 +3,6 @@ net10.0 enable enable - latest-Recommended true true diff --git a/Directory.Packages.props b/Directory.Packages.props index fb0dc40..a14f01e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,31 +1,31 @@ - - true - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - \ No newline at end of file + + true + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/PhoneForge.sln b/PhoneForge.sln index 197cd5b..775e2a8 100644 --- a/PhoneForge.sln +++ b/PhoneForge.sln @@ -23,10 +23,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTest EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{089100B1-113F-4E66-888A-E83F3999EAFD}" ProjectSection(SolutionItems) = preProject - Directory.Build.Props = Directory.Build.Props Directory.Packages.props = Directory.Packages.props PhoneForge.sln = PhoneForge.sln .editorconfig = .editorconfig + Directory.Build.props = Directory.Build.props EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntegrationTests", "tests\IntegrationTests\IntegrationTests.csproj", "{22D58F1F-ACCA-4BE5-B7F5-F8CB3E43C523}" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8ff2d8 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# PhoneForge + +[![build](https://github.com/nwdorian/PhoneForge/actions/workflows/build-validation.yml/badge.svg)](https://github.com/nwdorian/PhoneForge/actions/workflows/build-validation.yml) \ No newline at end of file diff --git a/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.cs b/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.cs index 5e3f06a..f40d028 100644 --- a/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.cs +++ b/backend/Infrastructure/Database/Migrations/20251124203322_RefactorToComplexProperties.cs @@ -13,22 +13,26 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.RenameColumn( name: "PhoneNumber", table: "Contacts", - newName: "PhoneNumber_Value"); + newName: "PhoneNumber_Value" + ); migrationBuilder.RenameColumn( name: "LastName", table: "Contacts", - newName: "LastName_Value"); + newName: "LastName_Value" + ); migrationBuilder.RenameColumn( name: "FirstName", table: "Contacts", - newName: "FirstName_Value"); + newName: "FirstName_Value" + ); migrationBuilder.RenameColumn( name: "Email", table: "Contacts", - newName: "Email_Value"); + newName: "Email_Value" + ); } /// @@ -37,22 +41,26 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.RenameColumn( name: "PhoneNumber_Value", table: "Contacts", - newName: "PhoneNumber"); + newName: "PhoneNumber" + ); migrationBuilder.RenameColumn( name: "LastName_Value", table: "Contacts", - newName: "LastName"); + newName: "LastName" + ); migrationBuilder.RenameColumn( name: "FirstName_Value", table: "Contacts", - newName: "FirstName"); + newName: "FirstName" + ); migrationBuilder.RenameColumn( name: "Email_Value", table: "Contacts", - newName: "Email"); + newName: "Email" + ); } } } diff --git a/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.cs b/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.cs index 04bb314..bc1ea12 100644 --- a/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.cs +++ b/backend/Infrastructure/Database/Migrations/20251124204711_FixColumnNames.cs @@ -13,22 +13,26 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.RenameColumn( name: "PhoneNumber_Value", table: "Contacts", - newName: "PhoneNumber"); + newName: "PhoneNumber" + ); migrationBuilder.RenameColumn( name: "LastName_Value", table: "Contacts", - newName: "LastName"); + newName: "LastName" + ); migrationBuilder.RenameColumn( name: "FirstName_Value", table: "Contacts", - newName: "FirstName"); + newName: "FirstName" + ); migrationBuilder.RenameColumn( name: "Email_Value", table: "Contacts", - newName: "Email"); + newName: "Email" + ); } /// @@ -37,22 +41,26 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.RenameColumn( name: "PhoneNumber", table: "Contacts", - newName: "PhoneNumber_Value"); + newName: "PhoneNumber_Value" + ); migrationBuilder.RenameColumn( name: "LastName", table: "Contacts", - newName: "LastName_Value"); + newName: "LastName_Value" + ); migrationBuilder.RenameColumn( name: "FirstName", table: "Contacts", - newName: "FirstName_Value"); + newName: "FirstName_Value" + ); migrationBuilder.RenameColumn( name: "Email", table: "Contacts", - newName: "Email_Value"); + newName: "Email_Value" + ); } } } From ec9864d7d36f9f31c695e0c0e949d7ddd5c204f8 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:37:14 +0100 Subject: [PATCH 36/46] build: add test verification GitHub Action workflow (#61) * add test validation file * change os from ubuntu to windows * remove push event trigger * rename workflow * add pull request event types * remove pull request types --- .github/workflows/test-validation.yml | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/test-validation.yml diff --git a/.github/workflows/test-validation.yml b/.github/workflows/test-validation.yml new file mode 100644 index 0000000..46b8f51 --- /dev/null +++ b/.github/workflows/test-validation.yml @@ -0,0 +1,34 @@ +name: tests + +on: + pull_request: + branches: + - main + - develop + paths: + - "**.cs" + - "**.csproj" + +env: + DOTNET_VERSION: "10.0.x" # The .NET SDK version to use + +jobs: + build-and-test: + name: build-and-test + runs-on: windows-latest + + steps: + - uses: actions/checkout@v5 + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --no-restore --verbosity normal From be51811f808f3b49d6480f87028843be6d6f09ec Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:41:03 +0100 Subject: [PATCH 37/46] docs: add status badge to README (#63) --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f8ff2d8..bb7cde8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # PhoneForge -[![build](https://github.com/nwdorian/PhoneForge/actions/workflows/build-validation.yml/badge.svg)](https://github.com/nwdorian/PhoneForge/actions/workflows/build-validation.yml) \ No newline at end of file +[![build](https://github.com/nwdorian/PhoneForge/actions/workflows/build-validation.yml/badge.svg)](https://github.com/nwdorian/PhoneForge/actions/workflows/build-validation.yml) +[![tests](https://github.com/nwdorian/PhoneForge/actions/workflows/test-validation.yml/badge.svg)](https://github.com/nwdorian/PhoneForge/actions/workflows/test-validation.yml) From a28e8a80236f98f749800b61ed2ed0a112cac537 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:01:19 +0100 Subject: [PATCH 38/46] feat: data seeding from excel document (#64) * add excel document processing service * fix document path and implement startup seeding * add Testing environment --- Directory.Packages.props | 1 + .../Core/DependencyInjection.cs | 2 + .../Infrastructure/Documents/ExcelService.cs | 77 ++++++++++++++++++ backend/Infrastructure/Infrastructure.csproj | 1 + .../Core/Extensions/MiddlewareExtensions.cs | 16 ++++ .../WebApi/appsettings.Testing.json | 0 documents/contacts.xlsx | Bin 0 -> 11344 bytes .../IntegrationTestWebAppFactory.cs | 30 +++---- .../IntegrationTests/IntegrationTests.csproj | 4 - 9 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 backend/Infrastructure/Documents/ExcelService.cs rename tests/IntegrationTests/integrationsettings.json => backend/WebApi/appsettings.Testing.json (100%) create mode 100644 documents/contacts.xlsx diff --git a/Directory.Packages.props b/Directory.Packages.props index a14f01e..4412d84 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/backend/Infrastructure/Core/DependencyInjection.cs b/backend/Infrastructure/Core/DependencyInjection.cs index 6abf47f..c7dfc1f 100644 --- a/backend/Infrastructure/Core/DependencyInjection.cs +++ b/backend/Infrastructure/Core/DependencyInjection.cs @@ -2,6 +2,7 @@ using Domain.Core.Abstractions; using Infrastructure.Core.Time; using Infrastructure.Database; +using Infrastructure.Documents; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -27,6 +28,7 @@ IConfiguration configuration ) { services.AddSingleton(); + services.AddScoped(); services.AddDatabase(configuration); return services; diff --git a/backend/Infrastructure/Documents/ExcelService.cs b/backend/Infrastructure/Documents/ExcelService.cs new file mode 100644 index 0000000..68f83de --- /dev/null +++ b/backend/Infrastructure/Documents/ExcelService.cs @@ -0,0 +1,77 @@ +using ClosedXML.Excel; +using Domain.Contacts; +using Domain.Core.Primitives; +using Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Documents; + +/// +/// Provides functionality for interacting with excel documents. +/// +/// The database context. +/// The logger used for diagnostic and error output +public class ExcelService(PhoneForgeDbContext context, ILogger logger) +{ + /// + /// Seeds data from excel spreadsheet to the database. + /// + /// The result of seeding process or an error. + public async Task SeedExcelDataAsync() + { + logger.LogInformation("Started seeding the database from excel document."); + + if (await context.Contacts.AnyAsync()) + { + logger.LogWarning("Data already exists in the database. Aborting process."); + return; + } + + List contacts = []; + + using XLWorkbook workbook = new("../../documents/contacts.xlsx"); + + IXLWorksheet worksheet = workbook.Worksheet(1); + + foreach (IXLRow? row in worksheet.RowsUsed()) + { + Result firstName = FirstName.Create(row.Cell(1).GetString()); + Result lastName = LastName.Create(row.Cell(2).GetString()); + Result email = Email.Create(row.Cell(3).GetString()); + Result phoneNumber = PhoneNumber.Create(row.Cell(4).GetString()); + + Result firstFailOrSuccess = Result.FirstFailOrSuccess( + firstName, + lastName, + email, + phoneNumber + ); + + if (firstFailOrSuccess.IsFailure) + { + logger.LogError( + "There was an error loading excel data at row {Row}. Error: {@Error}", + row.RowNumber(), + firstFailOrSuccess.Error + ); + return; + } + + Contact contact = Contact.Create( + firstName.Value, + lastName.Value, + email.Value, + phoneNumber.Value + ); + + contacts.Add(contact); + } + + context.AddRange(contacts); + + await context.SaveChangesAsync(); + + logger.LogInformation("Successfully seeded the database from excel document."); + } +} diff --git a/backend/Infrastructure/Infrastructure.csproj b/backend/Infrastructure/Infrastructure.csproj index e5ab340..02de220 100644 --- a/backend/Infrastructure/Infrastructure.csproj +++ b/backend/Infrastructure/Infrastructure.csproj @@ -6,6 +6,7 @@ + diff --git a/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs b/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs index a1a7b4a..1a4f4ec 100644 --- a/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs +++ b/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs @@ -1,4 +1,5 @@ using Infrastructure.Database; +using Infrastructure.Documents; using Microsoft.EntityFrameworkCore; using Serilog; using WebApi.Core.Middleware; @@ -31,6 +32,11 @@ this WebApplication app await app.ApplyMigrations(); + if (!app.Environment.IsEnvironment("Testing")) + { + await app.SeedDatabase(); + } + return app; } @@ -71,4 +77,14 @@ private static async Task ApplyMigrations(this IApplicationBuilder app) await dbContext.Database.MigrateAsync(); } + + private static async Task SeedDatabase(this IApplicationBuilder app) + { + using IServiceScope scope = app.ApplicationServices.CreateScope(); + + ExcelService excelService = + scope.ServiceProvider.GetRequiredService(); + + await excelService.SeedExcelDataAsync(); + } } diff --git a/tests/IntegrationTests/integrationsettings.json b/backend/WebApi/appsettings.Testing.json similarity index 100% rename from tests/IntegrationTests/integrationsettings.json rename to backend/WebApi/appsettings.Testing.json diff --git a/documents/contacts.xlsx b/documents/contacts.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1701a7e423aca50e988ed3fb4b0c7731059dc5a4 GIT binary patch literal 11344 zcmeHtgC%6;bo#4UUHCS+W2@>4hg9Zrh7J|D4cMsnrd+%@evito7 z_x3zb_jFgkXQr#pD}AaIWgs9i0MGzf002M=5atRPw*>4j;Nijvx%*< zzN&}4iIXm)yNxwTE+jZjHUJ!S|NplC;vOhh9klIcMrqghAt=_N9+neQS_(_hLZCyj z2b1a^uSwCkjZ5?Rp4wDNtp>J>QptK;hw6e4r(%IhOGIeXn(gU zlXyUWxq4JxYy?L(4&uoP^mGG^Z1aKKo=aN*X|#b8Stc6G}_vfSlQGms?wbZ)ff|RqJS7Ril6K6iKTuFO<`o~ z)|lh+h!P7~l!F!9uo5G%1;e4#uwa~%nE637kuSFlHM73zS#b9@ICz88lDS#g=%W9~ z8!#Cgsg|uUTkH1Oaw`V@BF*XIkbR(_B8*{3+vHIG2JWXjj*0h`9ew@3<;+}R;bNsKYMMI1lV5_kCktwY2f3-fqlvW> z6XUP_|J?Jx*eCz;=;iTp3O&pS!6%ZBApt)!|xeo{-w4N-X%_{*Kt z1SqO_A0XcNw);E|EiUs#{TLv<-e50}K*QiAZ*;2&PPun*fuW^xOqOscU;m2gI(s#H zo%TlBgT}QzhPJG^I7eo1m0V)_OsocFoKXW04!wXN7>hs6U%O9EYsKic5^PpP<)|XK zx``|MN5Xiz?_6@>KB8bKx9s6m8s>nLk=cBu_kcC&)g7LiiaEDMwNaKM51EJlJL@kO zV(FdePu>jj83W2>>}a>F<8S&Yv#)%$>)FqSGd;d?!S$34oDK#=5Erk2GS=UnqQ~?XE7e zBPOa*NvI|%jh3WbdNFeLmdoC;`*kU*$Q>#|jdo3HwD{Yd!~;CBsYvteDrNOzXoeU; z@D%jVKzLp(4=@y&WR@pYe=@q5p81dPQpvc1?L9VD)a%$^h+8MzN2S+zv}nz0Fr>>b zafNs}_7iSv4h*~#1C}k21cJAC;{{k%did#Zz2nuY`(Gg$e1BragSJr9h9mX(BxZH; z*`wIE!w9#%tiUQR_g2nPr366>SIIM;SuO$AC7Z7C@)RsL-h_o-3tF#g*9*+?-iv#j z(mDJ3gRIJ6SRr{AhvZSxy@rXJVT>{uS!3nir+BGXUN97p>q0UcvGlVUdo#2qSUF0~ z&1xv?M)VZq1hNd?4YysFm30s$2KS<{wsQ5;AN*_)buT$ypLezLERvDlNE+UGpf(cJtL2W8969UBf1h%YL6ZTe*Oeqs>Y?`6@x8#mhQIi^}GzVC=K$pq$ntyM8LhpmNY?WumF zHLm@8>V)qMTfQAqRrQ79~akg~czGu$=d7V(}@+#7y)p*DM5wQ-I zkOglpvhehKA-B<1TaGGWwopYaR+X5q4U!rHbwiC>yzo|zmgpGTTg;j?tOWWjnc-AR zgS9LB;mB;>_a)9I!rlmbhO30mOIdcU3F?d2t^DK1_YC7icgA*8$FhA1hKn=(ZvpGi zuc6EMQ8>9G4Aam`>?p#x?1^nLv#HyHEHFB&J+|T3SGU zfd_yE1Np=sx#O>%@$WnW1}gJFp7r0ol`G51d3cb*S%W2p%Soy0q8>V5w9=PmA^Fn#W5SHrT0mvY3KIzE=T$+7&H0Ni^IC}AB%4=5 z35jUt`3!1xjC(On+Xm8M$hp9vMnsHD*ero@Vu8`?Z#LeDD6nXF`V%~XoMt1#c#)@J zHaT5MLp|qu5&KBl2llr)+lQS8N(EO9e8wE)}&U)yAU{yi``l~9XrLn=3IV^N>G~8vMY-v zNy{|qkb4f}XCYW6uFvq6s&ca(rsN%8EQ!7Y8mTHabF4MZM7<@c-Ydt!D!XR z2Cf6zX0)r(;rIrr!Yxf^_UyBBULDfI$|Vb~$!0$=Mw&RfW`QMI$L?G2GbnvNKmTS& zVv4SO4>L+&mGU;AUfvj6Q9JA_c8N4)79cMC)8b|W`662%nh)J_$xWFXwMyUiwrBhN z#gR9j;7SK4algDv`u5XfOS49dXw#i~&)xa$qjA;n_Pr;sYip~XThSTW0yVX(9mDa- z_%ySvpu*;L?YF|V!WvE)^i^6P&yZL681G$uh%t3S@H{fNp4;v`U^Ygrg53+?&S^Ig z!yhA`R@)x1^r)k`oE6$Uj{HFlXXj8bQb^@fZUOkNI;YS0t{al&Qv;7JvWl`dn-O*O zq^8(U|p3(vcZP)eyTHHdVF*{wCv-l$R_f_Uac_-(Ky>Y51|(xIe+)8Sf1IHj5jZ8(pA z9k6Do@-p+Z&*f|}Z$v`(tibZs8>Kh)qhnubb*A0O?5}r92kqX@jm#&;(kck+rlOZ- z#GPw-7MmjtszI!AIC1vo59Li@>;zgGj6`qT#ahMboZHR_1&qMdpdqu`Mp^TaKSyVdO&tK;ch`u(Ya^`iTt8Q z;js|BwiVZ@(Vqxlvio?ew|~$XgJYcu@w18zLXNVRiWM6R=;7n~;XU(WY|RvCFe7DN zSEGonvWhu>ZD>JRa6m?kPOlh@KEyzcDpO2A&)%Y^u)}vo;K-30G~zwXy^EpGW8l_~ z&~>ZXsZ>)}5jFuD7_(Ax85VAKr7EQ`%$Koq7%{9^9w_o^)e41Ul9Jbi4IY>dIV9yV zo3a;D^zfGj4C1TB76{jOK`iPeV7`Ov6pkKBolHw?CxR4Ri&N_!15;Ksx|6(W6Z3^Q^fnQ}Q56ddmS-BkmNXW9DWG1J7m+h^yGju$4LnkVwNwq^I{x0hH|7gtvrN9Xz% zEo1ttwMF~hf($x^R_Dy)2RgZK?>B#PSFBcYT{xy?&0Tn=eL9h!^UzAUFd^KIKk{9> zerWUd@%UJHwfWDrl#4)`MFJ=>M&kX_%6=zCXLA!96Qg*tQV_PC zw?nG7{VTrpy!^r;73o3#mVdvIk0Ph0TH^OMBzJXK5mpY(~ zF~u5ZW)d}!2fCAmVU9K_n}9sNSw6tX-s?RNrSTy`fsC8mcALUFC+b_yNDEIAUE>}R zL+TK7wospKAhTUo!gr& zgVwvyUtL=|+aCWyr?|7a_yrl7RBRITJ@|%!#bQR}*X3Yj&eJIAqFoMzHgNGoiV*z0 zaN?PsS@XGAV4g<#Y4QmoUc-`ye}JW?M#`bl5L!*eHzVhi0tkR4iFg1-wC30uMpRC? zsMsJ4_V4hU2>Zr+^TuJH-Uis;M@9Tg|4zP z;VeNf*Ynf;!5)1DB7Po^Tc?#KN0aZm!h^C+UUxkjzVB~7U+glic6vX$e7myXn>%Cj zx!>ru>+Brjk8FnRy|Bw_bHCrZYtsLDwrL!3BOflo<1J}4l4*7O{qvv^PIizWQGuN) zOC$;ly&_}>L*)Y*tKVF5=z_hjb?~X{D_Ua+J-sE4W+JhD5fqtmjlA!GV>XFa`UdE= z(nFbuQ~S7)!^|RhLO1jra-CID=}5>MY^PWaBPU#m@7W$7a>cT7<*V(t*jz(vOq|_d zSvFb}`hc6(v>Ci#_sNV92V)~6MQ9@{V{y)F>rn$$@oVNgkDPHEqFO>?WGULPLbV5w zSvb1gaTy@PZ5Tsn3O&Ev#D-c^ASdr@_FsMWlO1O^k@pSYZkm6qVA!TTRTa?veY!&- zr6x=kio5=9Ni=eQfQT1?9Ams9LkeyGyTUDLB8{Uhv*CvmwhP3#n^>4cYq=YnfGFor z43JGiZ(XFt{RgNcX5xp3Q`1g12K`Q?9dUG=*5Tnq|$C9L-$BToHW-oW;vbv zoaWe$M{q-2IxW@ljl74N7WnBm-g6FKm(7+grJHVa#nCL#{cIt;#v>Dji84ZNbggo2 zU~ACOLyc{;6A?KWp-^gyH7~~_g|8yZk#J?C*T%w<+xJN-Rl}Og0azZCf9W3d-&S9| zYCj|ZG^&PfgpH`UQdAFB%8q?D_0(Z5(F3av*HjaV+fUwVadCHa)+%Y2C`3fl{+vcp z&l))axjS1zoRMKxsUt3p=*fyv)|8VyY2Fd@RtA;30Fiw%DS&FsVG;3%Kt@#5WZRLv z;VN><8G&WUFcecR+iO1j2zIaaRSB&fAU6NkJo+_hXI75TurDQ|QcJGer*pGH=mn(V zIt|bPr&W}5;97T0aUb&2rTV6UMDYAa+7Xe?uJEruSp;2`u=;vc*S~|8yb)&Sc9n3B zTM<(<^2wuxcuh(W?OUqFP7OK6HYcJ!vsw}cOwoN3Qkqr#7QM$Oau=B!;Q_5d)>4>v z{EcPe_?$&lh)eYK$3eL3Ota;>B1>FuIra$dX+=zKtA*anC%JRHG9xq&O_QmLM#Q#> zmT%Gr*@_i)UvcY>tz=>G8gkh_e0`|_}P)}IkW`wPmgw)w+%pxz3b6-p&K9tv} z$RJTakXF0>(G&6$#J=MI_sca5cVQ@vl0YV8V1T&3YjnmSbQ{@?ScgO)ompIbqgUsK zTU+%`NP+b9NlIIb29V=PzJ2EG$+O`^gRb572uzlqEWFI;deQ^{qvV|C>#1~v|4Dyi zR%|t%<^y`f-Uronuni<3A&4!?u&v-_@oJAQ$ zXk~;Dcwjy0*$*mtv(c0R2kBQEolb`oR(*Xqi!xTkpN4NTb>f>c*H-+01OwA@Pw9n- zl7eW{mWuWc)Q*|r=5WYlx|8yOJX6r^qk5gvcurfF=O2|f{o&{H_ZCt;qa0M;stA8% zQ&WLsN}3A5Y5u`)Ybhr+uSwqJfB^|l51UYYQ;pTDk^!4*x3IvJSR7`^+n2`8qd$P& zd*7o+E9Xj(?Yf?Up@+@ryyfDFu#tbW0jq94TRfEEuU}poj(I7tgsEq!oWbY6yX*1F zm_W>}+3h|pYe#b$qHbGMOBrZ^SEX3N4h#nr%XQsLl%B9QDJZTB)5&}!Uh%h&4TLq5 z%4wd;@}Cmk$q+*m_KI@qo|k7UQ%CK(t-*eyzE;Y=2M7JRNyOUW(!4gmK|AK_tMcCA z51&&JAx$zAlqcY8Aw7E)8Jyg&f-h!kpexZ9TKW+bo@Z9991-%|jr?@K(9Z*PyK$lh zn>>!iwYn`#{PIj6tRC!C5j%NJ5bCv)6C)3kD3*3?>T!^Sin25G7nB~<>H&H_o`d9RQ!Jd8DUUmI6B#)Wl5cIL_jsCGDR~xaD&zVxR*mowlyz0=H z60jfv={~j=p&pFYXM?BV!e=t)lsS?A6Uo4!{v8lTmjudL9)CT zHU5}j&w6EA;XZoSE9H&Y9$QQwMyEcFn;%wfjDCyR-AzB@)Q3{C|7lhGRlmA>nG42~ zE~5$`DMAnV9n5J8wN^M)3Q-6Cdq4i(j&Q85*4GG+-dKS@y@iv1IJiU_t_~$w#T_9MuZ2I+*CC6eVS!8A}T-(xXPH=FUFLo`yaBNK*iqQ^^qRWfK^M7 zoOTQY0AT#fh0F~dP2Q_b!*k6 zXo`PRmU8&$t5110fy>|!<6}41`fx2eJuR4-ahV+@!~wsNK$GfMT7zoI0q-O5)`#{( zSj}6Gx48jY)lldeV}d@Zc*fLY8fjV++7I{K2y)9J{iARl0gfYVKcaPRN;GmjX20N1 zgMXI~rVl@_-V}jKz?&Ny3kZNpXh7?nt9FOjQ6b+^;eDPZ$*9Qq%vVyA9_%WB=r-c- zVsS;{!Ec17=AeeY;Q$;%1!`^IrTkhgj5}SkSH+S=X7ci|BLdpkU5w zj+xF1S%-#$Y7P1JlKlsY11M><+%iIUDski!m|~<{)%+!w(I{k}>9*~BCdY=57%s-r zAz^Pcah%*4L`w!q^ZBE7mWonx?MyChd$Monl2W;+qez5tbfy;Z1s?0z=YU{!#~{ZN zRDEybMWnB(@8~Nv(=bX-9%(Ei7T#1f$5&rXb}aW8=VGe8ttBHhWXxxF*5g{blBl3B z-7FDopPwM4M{f%Bkas;LVy9t+Q>dtZgp#Z=WkWHJ8d}CQfV*&NJ`R(Nd6(MYx{Kk&yP z>?j>dpr_WjoZ=_Umvd`_FJ(hDcT>En%sV&1m5GRF|B-A={g{faOr#!8u?fBHJB)Rd zFQa@@{d9q}?8W~K|4-cHhCR3K1WCBCpx93S2RDBSz5mkg{!o7ZRPcW5zHJ&^wlg5A zlAj^RolvBM2dR&`BKyriVABvY*O(c*9iP8yG0HNQ9skXAc89L7=an7@5CZp9_H{94Ld#Nzx5F6}|GPUHEv_PoQboMP zQ4fKWmV5?iNrrLB+PYmFl3`Z)WrTYGx+``1s0FrfRZioLC8vg|;@Obo^MOn9m~Z9i zL;Lit|J99OBC*A!RtZLX-Up1J*M>x@fL*pvX2U*_BO%MqH~F&!J{=cnQT5O6=`3jO zv;5I@zPd2QoU(x{RlP!VES@mRg*0qQWEEd&7=`fM=rnhH=bm&4=htbR3p3r|u|{^J z2xA$;ltNQNw}Py%llBlkZiaHFM4M8I^g3goNv4e{Yw%R$@kOCV-nId;I*`WjdwSoh z=T5;yPZk)FJ@CjD=DFY^;lT6n+YqMugd31zeEKBKe1nRWPXUCN;n$1TiOLRq6MObp z<<&0eC0|^Z9cO;6w)EDZK!32(rWM8udG0+gk0BQ&Z z2A!MBo6N|2VJBP&jE0S+Xhjb)X`TS-ay}K^k(i7VOWIiRYG=A zu0$(7h2AZlpUHj4ez#ONiS(ic&KvXBl_$1y@0*301o+1-5i%>oX^VS{9E|!i*C_Fx zTzk3K6uAyHpt+`(x5cH2E%1)GcQGy>;2&dQZ z@8}0eciOo7an1ZcGRut-a$w?_v;9~P*oEW4c&>7G$BHVcsOeX5o?qQ$nt7TLlhI0` zgbegUK>-&Lf`tVmCHeEj-vRa!RK)!~H@!Rd%1{7}za1z)p@OD+-q{%|I@;MgF&W!A zn*3`q&;Mq5004k@ypl{0Ge*!V)U)WIS9*SdQ(z+mp_e1sYb9^^!DTG#>G*f94>yHJ z>U8MD2~Ni;PK<+C{iO&UUo_PY2qr_b0rkZNQ>_H;w(9EyP~RLFJhZF^CgJF{F`Bs2 z%u|aI`O;g!HZi_`NvJo;9h_YG9BgRaOGIl+elnrA^sR7XjCd4@>#g@W;aX}eZsiw; zLBf7QW@`yD9`XX5xY4deexX+gl&aa8z3jQS{RdxEugCrM(q7YT%SW9!NAsG0p^#rnte2eV>tGk9p)suzdC$%(SUOSR-UNt|fP3ba6W?zA@d zh`WrPF|o*t;)Ntyn|H@2_U;F|gJIxy+>`Zkfx7bVEHspYLkR=15EaBxl)tjj(BA%k zBm{NYzqX8cVY@|UgrF0Mdt!uTeC}mM1pjg)NsY^DL$LTpGr2pfn8wMO zdCwnFTVC$>Vp7WlJo=Zhlf8;z+qoD}koZ>E*GcZ*mvwzGq2l2h&RB#fqy07{^u3kS z3DDm;`a!*cEEkhWR9DWm$zf|Y;I5wfx`eyId17LXXSU9{kb(q0SXSGXY)=BqF_W^7 zK~wluO@SD(B9HL))WJ;_p?tO}v8^unHu=7&j@!t(_|~U-Eh!@+e815^zcw$AF)5?T z(kd#11LmO59g=bt`UlZSb$yfrZo?0Y*$)M&@Xo&OHX6CNHYm^;(w9`c=%F>xi!+f8 z3X)oay^oO$fSh>i)L>)on=z{!ai%vA*t>nC2H=M41P;3c5I-$Bsit|q4s)LkFrUf4 zUi7!)aT?>&OH;U~_9AS3*Buw6a@7-L<}KOkZUw71=OLiF`0ZWs9hCfm0OAb49qR+> zyXBA7A@{Qvoy>ecH!pjx&OMcU81Hn=*eF+j*8cpzA_@$g0aVQY^Qy>y57)ns|FA|< zQRY7Z{&T(7zYTvKi$Ff{m(^P@4PUNd`O|a+^fK~dMaxU$|D5dq(-Z($gZpj#|C#!K ziSu#>_)jEsg#Y^!f13|}iSkk|{}Y8Cq@IHw<)wK365yp)@h8A3?r(rUm5Z0AFW=Gr zG-V_F!}R4F+e?I(mGqwooJYE*wQ|5w;nlz{?q5CA{`eW*Y*#sL00`+rG5-!uRK literal 0 HcmV?d00001 diff --git a/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs b/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs index 1cf10da..89074f3 100644 --- a/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs +++ b/tests/IntegrationTests/Core/Abstractions/IntegrationTestWebAppFactory.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Infrastructure.Database; [assembly: ExcludeFromCodeCoverage] @@ -7,28 +6,21 @@ namespace IntegrationTests.Core.Abstractions; public class IntegrationTestWebAppFactory : WebApplicationFactory { - public IConfiguration? Configuration { get; private set; } - protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureAppConfiguration(config => - { - Configuration = new ConfigurationBuilder() - .AddJsonFile("integrationsettings.json") - .Build(); - - config.AddConfiguration(Configuration); - }); + builder.UseEnvironment("Testing"); - builder.ConfigureTestServices(services => - { - services.RemoveAll>(); + builder.ConfigureServices( + (context, services) => + { + services.RemoveAll>(); - string? connectionString = Configuration?.GetConnectionString( - "PhoneForgeTests" - ); + string? connectionString = context.Configuration.GetConnectionString( + "PhoneForgeTests" + ); - services.AddSqlServer(connectionString); - }); + services.AddSqlServer(connectionString); + } + ); } } diff --git a/tests/IntegrationTests/IntegrationTests.csproj b/tests/IntegrationTests/IntegrationTests.csproj index a7a7ad4..0c4267b 100644 --- a/tests/IntegrationTests/IntegrationTests.csproj +++ b/tests/IntegrationTests/IntegrationTests.csproj @@ -12,8 +12,4 @@ - - - - From 19d8bd2b569045f94a2ff9f8e4f35598e66445fd Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:41:15 +0100 Subject: [PATCH 39/46] feat: pdf report generation (#66) * add contacts report use case * make contacts report handler sealed --- Directory.Packages.props | 64 +++++++-------- backend/Application/Application.csproj | 4 + .../GenerateContactsReportCommand.cs | 15 ++++ .../GenerateContactsReportHandler.cs | 74 ++++++++++++++++++ .../Abstractions/Reports/IReportsService.cs | 16 ++++ .../Core/DependencyInjection.cs | 4 + .../Infrastructure/Documents/PdfService.cs | 54 +++++++++++++ backend/Infrastructure/Infrastructure.csproj | 5 ++ .../Infrastructure/Reports/ReportsService.cs | 49 ++++++++++++ .../Templates/ContactsReportTemplate.hbs | 58 ++++++++++++++ .../GenerateContactsReportEndpoint.cs | 39 +++++++++ .../GenerateContactsReportRequest.cs | 15 ++++ backend/WebApi/Core/Constants/Routes.cs | 5 ++ backend/WebApi/Core/HttpFiles/Contacts.http | 6 +- backend/WebApi/WebApi.csproj | 3 + documents/contacts.xlsx | Bin 11344 -> 12860 bytes 16 files changed, 379 insertions(+), 32 deletions(-) create mode 100644 backend/Application/Contacts/GenerateReport/GenerateContactsReportCommand.cs create mode 100644 backend/Application/Contacts/GenerateReport/GenerateContactsReportHandler.cs create mode 100644 backend/Application/Core/Abstractions/Reports/IReportsService.cs create mode 100644 backend/Infrastructure/Documents/PdfService.cs create mode 100644 backend/Infrastructure/Reports/ReportsService.cs create mode 100644 backend/Infrastructure/Templates/ContactsReportTemplate.hbs create mode 100644 backend/WebApi/Contacts/GenerateReport/GenerateContactsReportEndpoint.cs create mode 100644 backend/WebApi/Contacts/GenerateReport/GenerateContactsReportRequest.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 4412d84..ae7b640 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,32 +1,34 @@ - - true - - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/Application/Application.csproj b/backend/Application/Application.csproj index 74d5524..2eb64b2 100644 --- a/backend/Application/Application.csproj +++ b/backend/Application/Application.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/backend/Application/Contacts/GenerateReport/GenerateContactsReportCommand.cs b/backend/Application/Contacts/GenerateReport/GenerateContactsReportCommand.cs new file mode 100644 index 0000000..960cee4 --- /dev/null +++ b/backend/Application/Contacts/GenerateReport/GenerateContactsReportCommand.cs @@ -0,0 +1,15 @@ +using Application.Core.Abstractions.Messaging; + +namespace Application.Contacts.GenerateReport; + +/// +/// The command for generating a pdf report. +/// +/// The search term. +/// The sorting column. +/// The sorting order. +public record GenerateContactsReportCommand( + string? SearchTerm, + string SortColumn, + string SortOrder +) : ICommand; diff --git a/backend/Application/Contacts/GenerateReport/GenerateContactsReportHandler.cs b/backend/Application/Contacts/GenerateReport/GenerateContactsReportHandler.cs new file mode 100644 index 0000000..a019f8c --- /dev/null +++ b/backend/Application/Contacts/GenerateReport/GenerateContactsReportHandler.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using System.Linq.Expressions; +using Application.Core.Abstractions.Data; +using Application.Core.Abstractions.Messaging; +using Application.Core.Abstractions.Reports; +using Domain.Contacts; +using Domain.Core.Primitives; +using Microsoft.EntityFrameworkCore; + +namespace Application.Contacts.GenerateReport; + +internal sealed class GenerateContactsReportHandler( + IDbContext context, + IReportsService reportsService +) : ICommandHandler +{ + public async Task Handle( + GenerateContactsReportCommand command, + CancellationToken cancellationToken + ) + { + IQueryable contactsQuery = context.Contacts; + + if (!string.IsNullOrWhiteSpace(command.SearchTerm)) + { + contactsQuery = contactsQuery.Where(c => + c.FirstName.Value.Contains(command.SearchTerm) + || c.LastName.Value.Contains(command.SearchTerm) + || c.Email.Value.Contains(command.SearchTerm) + || c.PhoneNumber.Value.Contains(command.SearchTerm) + ); + } + + if (command.SortOrder?.ToLower(CultureInfo.InvariantCulture) == "desc") + { + contactsQuery = contactsQuery.OrderByDescending(GetSortProperty(command)); + } + else + { + contactsQuery = contactsQuery.OrderBy(GetSortProperty(command)); + } + + List contacts = await contactsQuery + .Select(c => new ContactResponse( + c.Id, + c.FirstName, + c.LastName, + c.FullName, + c.Email, + c.PhoneNumber, + c.CreatedOnUtc + )) + .ToListAsync(cancellationToken); + + await reportsService.GenerateContactsReport(contacts); + + return Result.Success(); + } + + private static Expression> GetSortProperty( + GenerateContactsReportCommand command + ) + { + return command.SortColumn.ToLower(CultureInfo.InvariantCulture) switch + { + "first_name" => contact => contact.FirstName.Value, + "last_name" => contact => contact.LastName.Value, + "email" => contact => contact.Email.Value, + "phone_number" => contact => contact.PhoneNumber.Value, + "created_on" => contact => contact.CreatedOnUtc, + _ => contact => contact.FirstName.Value, + }; + } +} diff --git a/backend/Application/Core/Abstractions/Reports/IReportsService.cs b/backend/Application/Core/Abstractions/Reports/IReportsService.cs new file mode 100644 index 0000000..1a16fea --- /dev/null +++ b/backend/Application/Core/Abstractions/Reports/IReportsService.cs @@ -0,0 +1,16 @@ +using Application.Contacts; + +namespace Application.Core.Abstractions.Reports; + +/// +/// Represents the reports service interface. +/// +public interface IReportsService +{ + /// + /// Gets the contacts report in HTML format. + /// + /// The contacts collection. + /// A task representing the asynchronous operation. + Task GenerateContactsReport(List contacts); +} diff --git a/backend/Infrastructure/Core/DependencyInjection.cs b/backend/Infrastructure/Core/DependencyInjection.cs index c7dfc1f..2ea50ab 100644 --- a/backend/Infrastructure/Core/DependencyInjection.cs +++ b/backend/Infrastructure/Core/DependencyInjection.cs @@ -1,8 +1,10 @@ using Application.Core.Abstractions.Data; +using Application.Core.Abstractions.Reports; using Domain.Core.Abstractions; using Infrastructure.Core.Time; using Infrastructure.Database; using Infrastructure.Documents; +using Infrastructure.Reports; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -29,6 +31,8 @@ IConfiguration configuration { services.AddSingleton(); services.AddScoped(); + services.AddScoped(); + services.AddDatabase(configuration); return services; diff --git a/backend/Infrastructure/Documents/PdfService.cs b/backend/Infrastructure/Documents/PdfService.cs new file mode 100644 index 0000000..34da59d --- /dev/null +++ b/backend/Infrastructure/Documents/PdfService.cs @@ -0,0 +1,54 @@ +using PuppeteerSharp; +using PuppeteerSharp.Media; + +namespace Infrastructure.Documents; + +/// +/// Represents the PDF service. +/// +public static class PdfService +{ + /// + public static async Task GeneratePdfReport(string html, string path, string name) + { + BrowserFetcher browserFetcher = new(); + await browserFetcher.DownloadAsync(); + + using IBrowser browser = await Puppeteer.LaunchAsync( + new LaunchOptions { Headless = true } + ); + + using IPage page = await browser.NewPageAsync(); + + await page.SetContentAsync(html); + + string outputPath = Path.Combine(path, name); + + await page.PdfAsync( + outputPath, + new PdfOptions + { + Format = PaperFormat.A4, + MarginOptions = new MarginOptions + { + Top = "60px", + Right = "20px", + Bottom = "60px", + Left = "20px", + }, + PrintBackground = true, + DisplayHeaderFooter = true, + HeaderTemplate = """ +
+ Generated on +
+ """, + FooterTemplate = """ +
+ Page of +
+ """, + } + ); + } +} diff --git a/backend/Infrastructure/Infrastructure.csproj b/backend/Infrastructure/Infrastructure.csproj index 02de220..6768255 100644 --- a/backend/Infrastructure/Infrastructure.csproj +++ b/backend/Infrastructure/Infrastructure.csproj @@ -7,6 +7,11 @@ + + + + + diff --git a/backend/Infrastructure/Reports/ReportsService.cs b/backend/Infrastructure/Reports/ReportsService.cs new file mode 100644 index 0000000..9dcdfa8 --- /dev/null +++ b/backend/Infrastructure/Reports/ReportsService.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using Application.Contacts; +using Application.Core.Abstractions.Reports; +using Domain.Core.Abstractions; +using HandlebarsDotNet; +using Infrastructure.Documents; + +namespace Infrastructure.Reports; + +/// +/// Represents the reports service. +/// +public class ReportsService(IDateTimeProvider dateTimeProvider) : IReportsService +{ + /// + public async Task GenerateContactsReport(List contacts) + { + string templatePath = "../Infrastructure/Templates/ContactsReportTemplate.hbs"; + string templateContent = await File.ReadAllTextAsync(templatePath); + + HandlebarsTemplate template = Handlebars.Compile(templateContent); + + Handlebars.RegisterHelper( + "formatDateTime", + (context, arguments) => + { + if (arguments[0] is DateTime dateTime) + { + return dateTime.ToString( + "d-MMM-yyyy HH:mm", + CultureInfo.InvariantCulture + ); + } + return arguments[0]?.ToString() == ""; + } + ); + + var data = new { contacts }; + + string html = template(data); + + string path = "../../documents"; + + string name = + $"contacts-report-{dateTimeProvider.UtcNow.ToString("yyMMddHHmm", CultureInfo.InvariantCulture)}.pdf"; + + await PdfService.GeneratePdfReport(html, path, name); + } +} diff --git a/backend/Infrastructure/Templates/ContactsReportTemplate.hbs b/backend/Infrastructure/Templates/ContactsReportTemplate.hbs new file mode 100644 index 0000000..bc12f45 --- /dev/null +++ b/backend/Infrastructure/Templates/ContactsReportTemplate.hbs @@ -0,0 +1,58 @@ + + + + + + Contacts Report + + + + +

Contacts

+ + + + + + + + + + + + {{#each Contacts}} + + + + + + + {{/each}} + +
NameEmailPhone numberCreated
{{FullName}}{{Email}}{{PhoneNumber}}{{formatDateTime CreatedOnUtc}}
+ + + \ No newline at end of file diff --git a/backend/WebApi/Contacts/GenerateReport/GenerateContactsReportEndpoint.cs b/backend/WebApi/Contacts/GenerateReport/GenerateContactsReportEndpoint.cs new file mode 100644 index 0000000..9e144b0 --- /dev/null +++ b/backend/WebApi/Contacts/GenerateReport/GenerateContactsReportEndpoint.cs @@ -0,0 +1,39 @@ +using Application.Contacts.GenerateReport; +using Application.Core.Abstractions.Messaging; +using Domain.Core.Primitives; +using WebApi.Core; +using WebApi.Core.Constants; +using WebApi.Core.Extensions; +using WebApi.Core.Infrastructure; + +namespace WebApi.Contacts.GenerateReport; + +internal sealed class GenerateContactsReportEndpoint : IEndpoint +{ + public const string Name = "GenerateContactsReport"; + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Routes.Contacts.GenerateReport, Handler) + .WithNameAndTags(Name, Tags.Contacts) + .Produces(StatusCodes.Status204NoContent) + .MapToApiVersion(1); + } + + private static async Task Handler( + [AsParameters] GenerateContactsReportRequest request, + ICommandHandler useCase, + CancellationToken cancellationToken + ) + { + GenerateContactsReportCommand command = new( + request.SearchTerm, + request.SortColumn, + request.SortOrder + ); + + Result result = await useCase.Handle(command, cancellationToken); + + return result.Match(Results.NoContent, CustomResults.Problem); + } +} diff --git a/backend/WebApi/Contacts/GenerateReport/GenerateContactsReportRequest.cs b/backend/WebApi/Contacts/GenerateReport/GenerateContactsReportRequest.cs new file mode 100644 index 0000000..d675ab5 --- /dev/null +++ b/backend/WebApi/Contacts/GenerateReport/GenerateContactsReportRequest.cs @@ -0,0 +1,15 @@ +using WebApi.Core.Constants; + +namespace WebApi.Contacts.GenerateReport; + +/// +/// Represents the request for generating a contacts report. +/// +/// The search term. +/// The sorting column. +/// The sorting order. +public sealed record GenerateContactsReportRequest( + string? SearchTerm, + string SortColumn = Pagination.DefaultSortColum, + string SortOrder = Pagination.DefaultSortOrder +); diff --git a/backend/WebApi/Core/Constants/Routes.cs b/backend/WebApi/Core/Constants/Routes.cs index 2af4f55..500fbfa 100644 --- a/backend/WebApi/Core/Constants/Routes.cs +++ b/backend/WebApi/Core/Constants/Routes.cs @@ -39,5 +39,10 @@ public static class Contacts /// The route used for updating a contact. ///
public const string Update = $"{Base}/{{contactId:guid}}"; + + /// + /// The route used to generate contacts report. + /// + public const string GenerateReport = $"{Base}/report"; } } diff --git a/backend/WebApi/Core/HttpFiles/Contacts.http b/backend/WebApi/Core/HttpFiles/Contacts.http index 516752c..e9a5ebc 100644 --- a/backend/WebApi/Core/HttpFiles/Contacts.http +++ b/backend/WebApi/Core/HttpFiles/Contacts.http @@ -37,4 +37,8 @@ Content-Type: application/json "lastName": "Doe", "email": "jdoe2@gmail.com", "phoneNumber": "091212121212" -} \ No newline at end of file +} + +### Generate contacts report + +GET {{HostAddress}}/api/v1/contacts/report?sortColumn=first_name \ No newline at end of file diff --git a/backend/WebApi/WebApi.csproj b/backend/WebApi/WebApi.csproj index 9f9afc4..dbb1f62 100644 --- a/backend/WebApi/WebApi.csproj +++ b/backend/WebApi/WebApi.csproj @@ -18,4 +18,7 @@ + + + diff --git a/documents/contacts.xlsx b/documents/contacts.xlsx index 1701a7e423aca50e988ed3fb4b0c7731059dc5a4..33327e1bd04c9244129807c926d3786d67d8a7c0 100644 GIT binary patch delta 5751 zcmY*dbyO7WvtEIvQ&zg9Q@T4Pq!vL^Iu;O?ZkLdd6c!{TB?Uz3Ub;(ILO@tbVrh_) zx_sZg_x$dC|CsZh^UQN*-kNu2ei*meeP6%^rij1zy$MHQGr$T3NJA_k_m8e}!tpl3 zH7nRzTa#NV%I4D5(=c_uV~DwYVmF+Dp#)QlAg^2YnUAdc<>Z9DTy-WIRo!I4Uouw6 zx;dWJaO$cIUveuoij%h{@fPFhYCMxj_(5*CeY1Dnh)npf8%gRy0BDNUEAgS0O4JlG zCG}y}9PX+G)xel&T0DQ3fx#)|@nDOM#H|*KQl|Jq?5C-WU8EyyT28zv<@x5~Oevcx zm?3G^;Cd(36SAi8K_A0~_nexc#^1AdN@+kQs*iPEnXA_fKHcLZuKfD0m4L*z;CLae z)2Hrb*JG|!V>EfFFU{2RW81l|OD5prI$bc~kRNM^T?s5o+NkDQ!)S@#$d4pyiNDbL zgVEPC(v%C0I;(*(WyeyjkH}WF77DHrRFOd1aB>=X@cdS@-GI@oj=v?!*qNcq1c_x6&?A0ILKGw)~%*Wx-d^Qbr z3THKM2Sb=p(htsT6n6}ifnW$12Z?Jr1{1igH63-N-W$f-dnTpphY%lT9avmptkZZ5 zr@@(;)N@-#m11X%e4jzR&>~naJ!YF;$&yi0%|GC4Q7&hu-`iGyqU35Kg`apJ&r ze;L9TA)_QrH;e;T?Ks`%6E?;UDrj1b6w#Zcs%=ko5N@c?g+c7Ve8opr}KReR-w>Ti30^uTM(LX;drf;vh={qFn=m z_*5i==_R)p^y+IbV_&M)yLPTz1Wbgj@2tP(sR8a5LA0{ixY&N+8w~jxPUXo{Lv80Um?96_ZCC}xl9E-+rnB1+wnVP zqh2M8snW^}l7ZB*lIqa68b6!GZP%_&o`7&koKp1Hv@f1G-xF#A1!sNAeijSvGKF*{ zfkiy@AHQfi@+26T$crvO->aYm)C^`@5%hU1#=YG44jMpF6z4r`X%7)4cYUnIUy?daK;kQq&JP1X5bY|bOpiY~UJ9W|9cNU!wWXU|=Iy^{1OV>u0sn~9OntXS z0aBH3k1qlBu#a^`5>UeFSyjS0g^np^;;HlV-{A1UOwq7C%f(D54%WPW2a=1t#>D~e zt0p>0oYgabUOPn&R)+G1Tzz!cOPj zTNidOb3nW}v2Gn?QD^Y=tSrYzouU5DJ30~K`wo2tv-WW{m=<-f36^!gZA(6xT~G+{ z!QFf^EG><8>>UUwEqeZn#q8kgA^F)8aDddPH}S?E}5z2u<(~W#@k_BDoO|`rd8qX9n znxEJ;noOO|cQoZB(Dbm%O;Im9Y(+b4A3}QvY)1k+Hv!HZj=B~t=MqxGg=nu_m1evd z$g#o^cFfwY0;i&yex4zBipeNk;4%?rZ7$<;WD%j}kyg;6C1QZe&%$U-XtHJY%9a8hf!>8@=C;=qhd+)?act${kWkn;eC6O4K&{qn{j_L0f3R zXo@|e?0nDX=!q$<;;j^O8PYajX$5<6Rd-h>UU?W&?6(5li@!I2MrOLA`*C1~9zMk_ zbA>`btR)IG2UfBgx;I*XZwVaSi0Kf{qTSzz&XrxmQ3 zPDq@W2y|hSjuwjj%Bws`mYb#LJ$KK8m-^Jy<9!Bs$JLJ>iSK?njjuTC_@uA=%7&GBdTnZevRBPz1d9J#Ukm!b>OoOn$J1RGL=q7dk~8ht zDB+rpJ&Jxx*O&8H5tAfiDhrjF~y)^tt#mf=1X+`;|( zlNsDV#sAZsj|QEaiq?c69x-R?^qSZs^vHV3`M}B8m6J0?=x5@J-E}FWO`9g(w5+4M zHszKg%tTg&71}cv-JhhNrcnQsiR=CpRM3aDs=S+voGByA8|q9M?JOT1U>Eovhle8? znt*_rEwmW2WyG1Lj#=zSjF@^Eq++gK5WY`vNT_l!^ph0ZY>BZun3ai3)V8ytB(Ds1 zeYER5huc^?35ctCbl!FLMx;}&sWX9aui-|1820Z2|5tc_9CI^!P8ey~ca%#cDV&|h zL@WTqYS}K=U19cgWvwSvRWt*IT)=WaF`ru2sw^lq~{{JhmU5CxlsMCmCJRZS?^Jz_qjC1mK|i| z8hf~RAsipav`pExEuvK(X?{2;AdZWEVOKxuIu_Xt5$~zPU27Ln4M_?&iYJ}4Pk3oN zI1T>eF>c*_0P}o}1v^IIj*EG#RDrbg@jtTsp2E=1w0_s=bc%R0{9w_=K*ESeOkE~u z`a90B6s`UF)5KMStZPxw>zsrYX2?_FI7X3jeo(pJ%NcDUox+-FwEuyT{@YOd;$r)) zpDTTngeJt)SjJM#=EaRBHiXv+$xqVxn(Vk(PPfr|Qfh2LFrJsER?c*vH_D+7^Anyx zH6xqAG$$flqbE#MBSm{DU~jQ<9y+ZS=4NIV9^rZ}>~Jh_V(1T>&sm-EkzWe0J`x-n z@bQS4L9d`&MHxNfl*=`HCDTGOsMsFdNmu7rQV1Is9`Saz@p^4fq;-a55?7b#hd6Fg z)|;HYR~HBA`P?WloU)?YE*?*ho?F6srZC^ZkUUuG+o*cuR4p)_gkLAZ9hg@^ zD=RDfn?ZV%3u(n1mw7?iqnTv3Gc=ja-S&p-^Ey5h<{5fwuMVI0#KE~vk>~xCmdif; z8e^(_kgr$rgZt0f-tXboskV#m53Kp{Ig$(Q2yZr2gwz^XvDku-+J zDP)M!qY9R>_+P)Umi1+C++0h_Wxnyw{{z9>8|(O@_GD_}$rzUdix^rAJQ$YSQhq=9 z0^8}r-JPuiryzXj$O~gdl=1|GLgw<{cF6L`x)nQWCXL3P7d7qYWc|p^wL%s!RD9;N z9P+GhG20Q$kmeCi0~3av6WJ1rhe?uaf8n0H{et-N@E1Lpbof-&z4CsHNwm6prrza;zRg($E2A;&@l3?6d=a)qiVZu4CtEVViw_U_xO{%j4_!u1}j zvpN%5+C*Yr$q$jvgKzn6H76+Aa)AkwcD&FWGLy}y^HG_>W<3KMWzBEbjSD0L3z|t! zUi8bnGw<8%vwt5ZXBA3S>^Vlo@GA5VkF42|sTM=QoAk8!{aNS}uQ5^f9XXDD7<}Fm zGNGAdxr2wzTn$ohNBNTU>+b^<%HPF64khjzgO)4B&qi(sdiXC&lXNF6-Q*Y<#{C*^ zkPf${Nl7Tw*7``J<*{Q2{lwP#A*yho=Qg?r5~ueydj0rDwiD96a~CK93u%_j%_W}L zx;{l7UmcwP_Ls^#$Q$3aplc*CxEW&{K$h|+MfY6YcKmmqDn*WiPn_4_*@c4z0BjKh z03ZPT5f26I-K#FoMKRKLzmog>$#x374@~pPPo!hXMXPgO?Dgj?Gd(6F3k${91809b zOtD!a{-~4`OaKjAYILVOeTAYGmBigGnoLJtGH2<$d#25Hw(0rzdVSIIWT&^@{FOO1 z-?P$*Zf#4xw|9^ObLp`51q+?shNWAnPt7$HM1GVGFb)x81kK_Rh1|ZwrmxY_@QhMj zLRK=TO>LBxY)-1fH?yNpp{Vwcu>HOhOFKb7PeFG|6gNwi*mp&X)RN4Jg}&ff6CERo zpNgEXu|KM)nr~2Rk)z$Q2?{iZYkoDhiPxW3i>qD!*kAcI@Qvp{SzJ5fJHL7QcHY{L zf>#r5VlaojetpGp^Q}bb2%UBj{AMRjz_(PH7!RC2E>2*}0b*ku2W9fwE0wZ-XZ7@b zTx>9|+zB&%>~v1G5%XO6&Z=HztZ;QpRpkb!v91!=MkJ%O2x(OYtBAJorsamdL@+!} z-^FEg*7DbE#8Z&aV@lRilQs7{=8;)rMhlOxy|CIO1~daPkdJ3A+YZ#+tUqOqV36EU zb2lxVV7%S2C#veoRw5IMaz0WtE+8Y%b)G-@s*vt8&_${V!hFJ%dg*w%ANu(Z;4@B3 z%r4%bq_GlN{HcrGfa1{3&v3t@>Scg1Q%Yxi!cCOp>(J#T<0t%`3 zJPl<8^xFB=1*itS8@Z;9hQ2e_VFu!48Q7Sy60WM`U)&s3X0^%{7}2>Knw()piJ_JX zpHlVU_H}~OWzKgt#CT&#w6ZpTk}!s9T(<$wuS=9S-7aYiyo=8M@~Wksx% zh)9oksvhkoQ*69<*V~KfYxZ808{o`5-Pl7M&@)*%X`pA<-sahA=f$B`3pnvKRz zd+Va!E=<*G)u~)Fl1H{Hhxc{B1UA=cU?UJ2#P>plR30_oUOhPPqVLuh(|rMr@gjVU z$Er5^Hk2i?Y^*-~xMm@LdF#>&x8~6@3ls6S9M2slHH-iB4hZ+a004xk;9HE`FypaAqyQ;`<(Sy;Kt!8>pt50!0RSN}w+aaTBAcL0 zrz;|MySkI9$>jU0(yG)c5dywO`fpwz&zxDu!09~?IQc3Zq$=1m=&qjCkDy2Pqbc3X zHJ*KtQPFIwOw6d*ojG-Ul_#K-EdTpQLI86`QCp=F*SfuhBPBFcrkD&4Yny*eqPLN} z7>G5e9CsEh)>?LTKr@40&Vti+R9^Or$2ofj(b?~I@artT**cC^iIzn(@p$Zk z`QtDejX%x+EO@1B&R0GPzJt3z_m6fsDf!>%f$@4ZEJo+VIIa0v zQqziEH^rD*d5d`3cx|g($NI~%BUqNLxSXW|?G5xN>XQ7Y6F-pAN4_4E&4Y6L43^3F zk?g4;A|>ksAw^1%o|V3Ef6=};jv7*5{7(DOBG(^RX0#b7)%Q&>xX0X7O0rcTxUodv zu|KRR#l%9LWjFbvfagcW*ubyB;9dp;xe03i_(5UiKfkVwmI~BTsLi4P5m{>qR&Lb3 zp64n0`dYba=wD{06Q2H09?XS>NB7m~Rfz-x41pA-5|_JzEYf zJ5z<3OX>?AB;9}993#*B)t4vV6OdKCD!=YlPo2WHNK%zNP>W?9QXf-S8)zr^pkuA< zvKh}kq+{pA;$fT<8=ty#1qUVc{*`bAEs_x1fP7wAvOJ3)^!i}7@dqwbUukEjVr4g) z_{Z;Q6dGau`OdQc3&L-vijHrW%|lzg{eeOgZ!L}D4V@YVvv^x zbeqY|xgvwa4A$Bgb*$R}%qH`Ud*aWY;n)Q1cVPNV4nBGWId4D%!fneG!p@0HHDY0H z43(a+*W0Pd1{yGt0wmW#2A@Aa@j(PUq8gG_l<6&~bO-AA#$ee}UOl~EDUmNkne9n1 zI@e%6Z6ri5+l5G;J@k~BT97mvy)vY_QVVaAf4#u}oG)xSZ6^;v7U^Hd?`5>iP7-(a zIE`APRIoW=$UA4;De$@Q$t3%wPe;NcW0^rB?ESomPVM(u>H^ln4F?q&%wamq;N%n9 ze_K_y?9!P3)1NKm0%M`@008(M9u3@yhm`g|9V`HV>Y>>A&v*=%<5tBOrhrFs%M&Hi z0|5T6{9fJxuJ&GVeJ&8)|4%9YLjwRNnH~}hcDnzB6aawb|Kb3AHux?h+k<&-j5ZGV r?o%#!I}a({zlZ+_0C0Z@LuCI?zl{sN!y}8K$P4G>WyS{Z{{#LPo+I%V delta 4233 zcmY*cc{mhY`yOQ98N0E|IvEN{NGRE|4aUx7O=LI7ME0_dWn_u5k1Rt;WFkp+S+cKV z>y>5fYxC9n`>w0+cm8;;bI$WT=lpZu*L^=99t>I4O;J&{&N)u3q2{RskV_DjCk`GY z;oY#8*P{U@TAhJhl`Xeh@j}qc@}EU;4UJ|M*k5PKC@MX~k4C;oiGhhOH`nCNz2|lX zuoQFY%OTM_InQN#;As$i^3Z1g=$44S$0zo5S|ibYZI0%3Bq<2b;nx6khLZs-S@qst zx!q;rtLKjy(AHE5vADg8)Yh`B0YP>3*fLGAdwoP(<;Q-Xu47qmpRh9Eta0dM9oyB6 z4PNcx7w(0%rWO@c;1mt=Z1(q3^s1Z)G8X+T`3v(%5t)>Z$(Qu^vUs9fJ03lhSmbIG zY#9pa*IT#&lXk-a^BBV4v7w(?B?FAdBs3jjh@uHBlxf`GA;YA* z$jid0>}%J;$ox*jR0hQZ3J;$dm~|(ViMA{1Zf2fZx7WhHvlO@H^iFZV zD`MQ+FsgEsP~*cKyER%6R3ts;xNJmS%|&yE%1KtYa%3Fo%(qT+Vm!${E?58X<^-dD z7Qg$=mfDm@KU>O(t>~xyxS+0e3(y0n=I>htL~Sq82XN-7&>LIcGHYu30iFRXr;w6g zhp75Mx);7J@YTq3#s(UYJn#66vYm-C73bv-MdqNwTh;ZPwc5x>Oi%`iw>W6J=5i5C zYkiIOeQ??rn$E*w1WeNm|Tq%y!oGn`@FX&w|XQ{dBrvY0+>Q5)Pk1rG|G zzswutBceNnN z0Z>I0z=loQtK@WH$q+>X6#x*!g_33vLQW@73$es*%WSF&ZNXpoPrT-Ed+P}d+{Ok? z+q2(HHfd|RWrRK50_9zYNttp~yHHS(<~1k?lY8y%^%QHFkLY?(8Eoz)U}(dY^^|uj zj(Q2q7^#>>M&Le8f%>4JdjH|~N8yYtG;F*MLpP$8-1>TXU+g42Dr(Q{xgko9M>Z?& zRa2M*i4?=K%Gh0_J=YUbTf(>-u^G`ZnKl%L*ERXwSs@V)Ro{=UiEg*^EGkV02~~Tr z;!Le$;MAp`zn%xqT1i;&?y2vjr7kb*4D9e6D$SlJp-hHhK2@ngIr+|bLsEj8ys%u! z^!fIu9ajH3Ga{h2;wM~~NQ}uu+BtPFf4aUuAgeVGW%f-I0^-LL_?{JixYM*XBiw7!H zfWoP&>6^boEJ-c?3Gg?LTq#F{IiJa~eapLg6`w8Qe1b@nqs>h2H@_Gl;g!oPzGU{! zNtnR&BvGh!5{*}a$#Fwb6>D70e3imz#g|Hs<2rLzorIZF$Nt*AB_3ZkLUNGv&%q_( z`^S>`g=3_E;s&O(xqW#a7ebd*ik@IHZc0Ac#^-w{i5UPOkpO=}Mz)<%2ZSZM)^r8% z#t6=3Y@P6#t5Q!KSpY~&d^JsYD>{Ak3tNQDY~@i^u!Px{m6g!e$-T{RUX3}tl3Iv17b5Wq?DGpOG$l?>Gg1M6km|d4v8;lN%!^e*YjorV}m2Y^M*IVuGUg~Vr_X! z^yonP26hQo-iu2*Q#r6Hma9qKRJ4$C53&Xir=X>;Gb3ISlEY;K!&Et8FHwwa5C@lB zW^kWT9{R)=Nv0EFugm9LrZOo+wt5ZRw06enDq+;;r72lYUCOAiJ^4(NoH;3xTF@Gu zfBnzV_yMyXpR+6T?;k90Rs?~EFh-+4Z@(Vqcb!jY>c$3*0YWBJk64N|zCn*IEp zFM-7A;=Vg}#8OYXyC!!Z_YZuQwiyW)d3?Bv?e?)9@0-j>mA>{z{!CJ;g)7)o4$r0R<_>leV6dt=^S|vRy5xqDyH70n2S2OI!7Zs$o02%9+kT z3yJXXT=k+e$c)2C_0f)f%5!lQ_c`Kb8uU-0}J0y6u(`IQ--I&@B`!u2qc z^6?bwTwjaI)!R7*rk{%ybcRHUNuF*ka_^YyNGSU;&l`(rY{iwo8uvKyL;O15C3XnR z%Z_x+R-#AxcYpaIgRRwPAD$!O2-2h8Uq;adeiAg_*jcD4U@rk#B1M=>4jUTf(qHJy7r{KkC!8| z=>AEqhbiisJ~ey0lbCgdgIuS#@w7k`M1Jw-K~8p?cyY}h?nq=bNOzfL%4#InmvYuO z<|aUKF4F7CHDTHb(>I^c9P2hu3rac*z_hU)bREANxf6q9l9DlO8yRX^&WWiMs@~Oy z=;T-Y)P-ME>sJ1efs=C*Z>cG0cVFtihSjcstK0MOJmn>+0+&ZiEg?+#hjlF4^9tGl zpWimKO!+c1CL?pJyOVzcS(B=aQS61wvSL1b0St;o_qv@`e_~EjWQ)Nn*3a#NajP{@ z3o}g@DMz@1xUy3jp&f0K4W6vJrsxcw^V~32h1K6mvNPd%Rj)z( ztfN|SxEc?8TvZ>ytn$x*8!q&A_)k2^b*Wg%b-!%+sr(96BTUWp`;W{vZO6%6ZA}fg zsrKtMvCv^KWkhgog}8g}x91XSwQY3lU3I#9yw6#?cW_|2x$CgkJm4^2 zU;ZOp18h_Fa2GPTVS@>NLinXpJzt}|=a*A3z89MFYTIZW0n6TV0j;EOA{P$NK7>ag zUX~pY{wshJoYZu59idV4p5y?)7B%XJ5FZk1Xf_RDX$>wT2@}F_U}SJQz_4gYivQ^+ z7%tmN=ey}W-e{SrzN{S=F1^;D#fP`8bc zewKi;H~C31dm<5HBb zV0$#f*>t!uql}(lUH8e&Mfqy1sCB-w^<$Cm?7H_bJN-ACK1lTLj9Nejv z#S$ttS|^7?{`7+)g2{#0D$4+@iCK42|QvF;D-lQr|Mm=k-P; zHwcgILCH#3?hmVhN(|kCKTx~ORB5iRz&@IYR`>K^<9VK682FeB*W=yin%2q?ZjDRm zOr5N=zNFM!=2nIFQE&0p)=VEY<{k$dAJr7^y71^fPrvq07F^;S&1EWJktjx>T0!=5 zZkFhq9@Sm!G0ldlk?8*zFeVk6UGI-%000dm03ZrL$;pc&KUlVVkN(D=D3LjBTP&A6 z#-cxn`ROPHm!u#B=LQen%=xM`xw@Sr!1BhE65VsRtGjO`jIfsdymxvbmlE!0@a&)I zB+t}qe0N6*@pj121A9d&3Z5N<>lGZR<{5mrFOgF1!HFbHx%>{+m^Y&zp}yT*0;bXh z59-!8tZ^fSdKHXj8BPK0K@z$B9$d)UqQ)anB}-RhVvjCqBQR?KS<`>kI&vI&aP%}o z(Bm7d64+Y&3>b6WNx&SicJ-B8Z^VnfxY>ZCk}>`W{9aDd8&XKFJX^?^T5d%r_Zt(`oSgj(*FYJ(@$wkS4vix9r&c4-qpwue~Ch^(v z%Q0Sund=NM3Gph~$*$tJI|I0gx;X=;mN%-4Zzi$6IQ}5TiDw$%>*%z#zzkC-e=Bts zIlEy{RvgH~#7(dE;}HlQk?1H4eDz8Xa>UA6A|^$zuV$ZalT;XeHnEo znC5=-5ISS)SnAMS^QeV7jCuTt>Wxt8q1MWd@)_;Uml`rD_uUAcL%PlGOk8Y#@h53nG zHwmZjp5^o!Nq-P@c2u@$A;T_+^54q#ztYXwg=N6ESwT1Wzr_~<-c*zhxA3=mTP*TZ ze)k0yF3`(NgZT{`L&DTXYQK=7ss4*D%Lj8*S!?gOT<* z*JW0WlC}eqRbBMuea zot@v>Wk$V&y?PqnU2Sug>BI5$Y~<~Q?$pwcCpn9>sgWsNu?k!(V1$*oMMXTVp_T&u zz2+kSJKI(U>dluvuC_FF=mZWB<>ZaDc;hm>+&`sfbpD7AAp`Q@PenSF|8HQqFRM!N z-!VN!&h*j(EdYSJpyfr8ojy00030*8kpJ1)(+}m-+ucasRhsQ1Ca6?~@M-qn^sE l@c#>h-vsyhtuL(qTLu^uMUBa;kwY(|fC?g1K&d~;{{VC(-`)TK From 4522642603aff5c62e9fbbbe627adc6ba91f63d9 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:47:32 +0100 Subject: [PATCH 40/46] build: update build and test workflows (#68) --- .github/workflows/build-validation.yml | 3 --- .github/workflows/test-validation.yml | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/build-validation.yml b/.github/workflows/build-validation.yml index c3dad0a..793cd3e 100644 --- a/.github/workflows/build-validation.yml +++ b/.github/workflows/build-validation.yml @@ -5,9 +5,6 @@ on: branches: - main - develop - paths: - - "**.cs" - - "**.csproj" env: DOTNET_VERSION: "10.0.x" # The .NET SDK version to use diff --git a/.github/workflows/test-validation.yml b/.github/workflows/test-validation.yml index 46b8f51..a348eb6 100644 --- a/.github/workflows/test-validation.yml +++ b/.github/workflows/test-validation.yml @@ -5,9 +5,6 @@ on: branches: - main - develop - paths: - - "**.cs" - - "**.csproj" env: DOTNET_VERSION: "10.0.x" # The .NET SDK version to use @@ -31,4 +28,4 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Test - run: dotnet test --no-restore --verbosity normal + run: dotnet test --configuration Release --no-restore --no-build --verbosity normal From 2a6bd8652a0e65817f83cfd57476b9e75ffb87f9 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:33:56 +0100 Subject: [PATCH 41/46] refactor: database seeding (#71) --- .../Core/DependencyInjection.cs | 2 + .../Database/Seeding/SeedingService.cs | 64 +++++++++++++++++++ .../Infrastructure/Documents/ExcelService.cs | 49 ++++++++------ backend/Infrastructure/Infrastructure.csproj | 1 + .../Core/Extensions/MiddlewareExtensions.cs | 8 +-- 5 files changed, 100 insertions(+), 24 deletions(-) create mode 100644 backend/Infrastructure/Database/Seeding/SeedingService.cs diff --git a/backend/Infrastructure/Core/DependencyInjection.cs b/backend/Infrastructure/Core/DependencyInjection.cs index 2ea50ab..2800d84 100644 --- a/backend/Infrastructure/Core/DependencyInjection.cs +++ b/backend/Infrastructure/Core/DependencyInjection.cs @@ -3,6 +3,7 @@ using Domain.Core.Abstractions; using Infrastructure.Core.Time; using Infrastructure.Database; +using Infrastructure.Database.Seeding; using Infrastructure.Documents; using Infrastructure.Reports; using Microsoft.EntityFrameworkCore; @@ -31,6 +32,7 @@ IConfiguration configuration { services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddDatabase(configuration); diff --git a/backend/Infrastructure/Database/Seeding/SeedingService.cs b/backend/Infrastructure/Database/Seeding/SeedingService.cs new file mode 100644 index 0000000..af642b5 --- /dev/null +++ b/backend/Infrastructure/Database/Seeding/SeedingService.cs @@ -0,0 +1,64 @@ +using Domain.Contacts; +using Domain.Core.Primitives; +using Infrastructure.Documents; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Database.Seeding; + +/// +/// Represents the seeding service. +/// +/// The excel service. +/// The EF Core database context. +/// The logger used for diagnostic and error output. +public class SeedingService( + ExcelService excelService, + PhoneForgeDbContext context, + ILogger logger +) +{ + /// + /// Reads contacts from the Excel document and seeds them into the database. + /// + /// + /// This method attempts to load contacts using . + /// If loading fails for any reason (e.g., invalid data), + /// the method exits without modifying the database. + /// Seeding is skipped when the contacts table already contains data. + /// + /// + /// A task representing the asynchronous operation. + /// The task completes when the contacts have been saved, + /// or exits early if excel parsing failed or the database already contains contacts. + /// + public async Task SeedContacts() + { + logger.LogInformation("Seeding contacts to the database."); + + Result> getContactsResult = await excelService.GetContacts(); + + if (getContactsResult.IsFailure) + { + return; + } + + List contacts = getContactsResult.Value; + + if (await context.Contacts.AnyAsync()) + { + logger.LogWarning( + "Contacts already exists in the database. Aborting process." + ); + return; + } + + context.AddRange(contacts); + + await context.SaveChangesAsync(); + logger.LogInformation( + "Successfully seeded {Count} contacts to the database.", + contacts.Count + ); + } +} diff --git a/backend/Infrastructure/Documents/ExcelService.cs b/backend/Infrastructure/Documents/ExcelService.cs index 68f83de..07a6311 100644 --- a/backend/Infrastructure/Documents/ExcelService.cs +++ b/backend/Infrastructure/Documents/ExcelService.cs @@ -1,32 +1,31 @@ using ClosedXML.Excel; using Domain.Contacts; using Domain.Core.Primitives; -using Infrastructure.Database; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Infrastructure.Documents; /// -/// Provides functionality for interacting with excel documents. +/// Represents the excel service. /// -/// The database context. -/// The logger used for diagnostic and error output -public class ExcelService(PhoneForgeDbContext context, ILogger logger) +/// The logger used for diagnostic and error output. +public class ExcelService(ILogger logger) { /// - /// Seeds data from excel spreadsheet to the database. + /// Reads contacts from the Excel document and loads them into memory. /// - /// The result of seeding process or an error. - public async Task SeedExcelDataAsync() + /// + /// This method processes each row of the Excel worksheet and attempts to + /// create a instance using the domain value objects. + /// If any validation error occurs while reading a row, the operation is aborted + /// and the error is logged. + /// + /// A containing a list of successfully parsed entities, + /// or an error describing why loading failed. + /// + public async Task>> GetContacts() { - logger.LogInformation("Started seeding the database from excel document."); - - if (await context.Contacts.AnyAsync()) - { - logger.LogWarning("Data already exists in the database. Aborting process."); - return; - } + logger.LogInformation("Loading contacts from excel document."); List contacts = []; @@ -55,7 +54,7 @@ public async Task SeedExcelDataAsync() row.RowNumber(), firstFailOrSuccess.Error ); - return; + return firstFailOrSuccess.Error; } Contact contact = Contact.Create( @@ -68,10 +67,20 @@ public async Task SeedExcelDataAsync() contacts.Add(contact); } - context.AddRange(contacts); + if (contacts.Count == 0) + { + logger.LogInformation("No contacts found in excel document."); + return Error.Failure( + "ExcelDocument.Empty", + "No contacts found in excel document." + ); + } - await context.SaveChangesAsync(); + logger.LogInformation( + "Successfully retrieved {Count} contacts from excel document.", + contacts.Count + ); - logger.LogInformation("Successfully seeded the database from excel document."); + return contacts; } } diff --git a/backend/Infrastructure/Infrastructure.csproj b/backend/Infrastructure/Infrastructure.csproj index 6768255..ee89bbe 100644 --- a/backend/Infrastructure/Infrastructure.csproj +++ b/backend/Infrastructure/Infrastructure.csproj @@ -12,6 +12,7 @@ + diff --git a/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs b/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs index 1a4f4ec..ffdbdac 100644 --- a/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs +++ b/backend/WebApi/Core/Extensions/MiddlewareExtensions.cs @@ -1,5 +1,5 @@ using Infrastructure.Database; -using Infrastructure.Documents; +using Infrastructure.Database.Seeding; using Microsoft.EntityFrameworkCore; using Serilog; using WebApi.Core.Middleware; @@ -82,9 +82,9 @@ private static async Task SeedDatabase(this IApplicationBuilder app) { using IServiceScope scope = app.ApplicationServices.CreateScope(); - ExcelService excelService = - scope.ServiceProvider.GetRequiredService(); + SeedingService excelService = + scope.ServiceProvider.GetRequiredService(); - await excelService.SeedExcelDataAsync(); + await excelService.SeedContacts(); } } From bc9acd6ba31bfb8f0fba5a1c893f99e8bd675c18 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:34:58 +0100 Subject: [PATCH 42/46] feat: console UI (#72) * add GetContacts feature * refactor GetContacts use case * add Create, Delete and Update features * remove CA2000 from .editorconfig --- Directory.Build.props | 30 ++-- Directory.Packages.props | 69 ++++---- frontend/Console/Console.csproj | 18 ++ frontend/Console/Contacts/Contact.cs | 11 ++ frontend/Console/Contacts/ContactsLayout.cs | 137 ++++++++++++++ frontend/Console/Contacts/ContactsService.cs | 145 +++++++++++++++ frontend/Console/Contacts/ContactsView.cs | 101 +++++++++++ .../Console/Contacts/ContactsViewState.cs | 10 ++ .../Console/Contacts/Create/CreateContact.cs | 55 ++++++ .../Contacts/Create/CreateContactRequest.cs | 8 + .../Console/Contacts/Delete/DeleteContact.cs | 57 ++++++ .../GenerateReport/GenerateContactsReport.cs | 36 ++++ .../GenerateContactsReportRequest.cs | 7 + .../Contacts/Get/GetContactsRequest.cs | 9 + .../Contacts/Get/GetContactsResponse.cs | 11 ++ frontend/Console/Contacts/IContactsClient.cs | 25 +++ .../Console/Contacts/Update/UpdateContact.cs | 104 +++++++++++ .../Contacts/Update/UpdateContactRequest.cs | 8 + frontend/Console/Core/DependencyInjection.cs | 37 ++++ .../Console/Core/Enums/ContactsSortColumn.cs | 10 ++ frontend/Console/Core/Enums/PageSize.cs | 8 + frontend/Console/Core/Enums/SortOrder.cs | 7 + frontend/Console/Core/Input/UserInput.cs | 167 ++++++++++++++++++ .../Core/Validation/InputValidation.cs | 58 ++++++ frontend/Console/Program.cs | 16 +- frontend/Console/appsettings.json | 6 + 26 files changed, 1100 insertions(+), 50 deletions(-) create mode 100644 frontend/Console/Contacts/Contact.cs create mode 100644 frontend/Console/Contacts/ContactsLayout.cs create mode 100644 frontend/Console/Contacts/ContactsService.cs create mode 100644 frontend/Console/Contacts/ContactsView.cs create mode 100644 frontend/Console/Contacts/ContactsViewState.cs create mode 100644 frontend/Console/Contacts/Create/CreateContact.cs create mode 100644 frontend/Console/Contacts/Create/CreateContactRequest.cs create mode 100644 frontend/Console/Contacts/Delete/DeleteContact.cs create mode 100644 frontend/Console/Contacts/GenerateReport/GenerateContactsReport.cs create mode 100644 frontend/Console/Contacts/GenerateReport/GenerateContactsReportRequest.cs create mode 100644 frontend/Console/Contacts/Get/GetContactsRequest.cs create mode 100644 frontend/Console/Contacts/Get/GetContactsResponse.cs create mode 100644 frontend/Console/Contacts/IContactsClient.cs create mode 100644 frontend/Console/Contacts/Update/UpdateContact.cs create mode 100644 frontend/Console/Contacts/Update/UpdateContactRequest.cs create mode 100644 frontend/Console/Core/DependencyInjection.cs create mode 100644 frontend/Console/Core/Enums/ContactsSortColumn.cs create mode 100644 frontend/Console/Core/Enums/PageSize.cs create mode 100644 frontend/Console/Core/Enums/SortOrder.cs create mode 100644 frontend/Console/Core/Input/UserInput.cs create mode 100644 frontend/Console/Core/Validation/InputValidation.cs create mode 100644 frontend/Console/appsettings.json diff --git a/Directory.Build.props b/Directory.Build.props index 025a729..f51c3df 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,17 +1,17 @@ - - net10.0 - enable - enable - latest-Recommended - true - true - true - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + net10.0 + enable + enable + latest-Recommended + true + true + true + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/Directory.Packages.props b/Directory.Packages.props index ae7b640..5f9d6be 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,34 +1,37 @@ - - true - - - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - \ No newline at end of file + + true + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + diff --git a/frontend/Console/Console.csproj b/frontend/Console/Console.csproj index 052197a..8d75346 100644 --- a/frontend/Console/Console.csproj +++ b/frontend/Console/Console.csproj @@ -3,4 +3,22 @@ Exe True + + + + + + + + + + + + + + + + + + diff --git a/frontend/Console/Contacts/Contact.cs b/frontend/Console/Contacts/Contact.cs new file mode 100644 index 0000000..09d0ad3 --- /dev/null +++ b/frontend/Console/Contacts/Contact.cs @@ -0,0 +1,11 @@ +namespace Console.Contacts; + +internal sealed record Contact( + Guid Id, + string FirstName, + string LastName, + string FullName, + string Email, + string PhoneNumber, + DateTime CreatedOnUtc +); diff --git a/frontend/Console/Contacts/ContactsLayout.cs b/frontend/Console/Contacts/ContactsLayout.cs new file mode 100644 index 0000000..a658024 --- /dev/null +++ b/frontend/Console/Contacts/ContactsLayout.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using Console.Contacts.Get; +using Spectre.Console; + +namespace Console.Contacts; + +internal static class ContactsLayout +{ + public static void Display(GetContactsResponse response, GetContactsRequest request) + { + Table table = new(); + + table.Border(TableBorder.None); + + table.AddColumn(new TableColumn("[blue]Contacts[/]").Centered()); + table.AddColumn(new TableColumn("[blue]Options[/]").Centered()); + + table.AddRow(RenderContacts(response.Items), RenderOptions(request)); + + AnsiConsole.Clear(); + AnsiConsole.Write(table); + + AnsiConsole.MarkupLine( + CultureInfo.InvariantCulture, + $"{(response.HasPreviousPage ? "<" : "[grey]<[/]")} page {response.Page} of {response.TotalPages} {(response.HasNextPage ? ">" : "[grey]>[/]")}" + ); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[grey]Use left and right arrow keys to navigate.[/]"); + AnsiConsole.MarkupLine( + $"[grey]Press [white on grey] Escape [/] to exit or choose an option.[/]" + ); + AnsiConsole.WriteLine(); + } + + private static Table RenderContacts(IReadOnlyCollection contacts) + { + Table table = new() { ShowRowSeparators = true }; + + table.AddColumn(new TableColumn("[deepskyblue1]#[/]").Centered()); + table.AddColumn(new TableColumn("[deepskyblue1]Name[/]").Centered()); + table.AddColumn(new TableColumn("[deepskyblue1]Email[/]").Centered()); + table.AddColumn(new TableColumn("[deepskyblue1]Phone number[/]").Centered()); + table.AddColumn(new TableColumn("[deepskyblue1]Created[/]").Centered()); + + foreach ((int index, Contact contact) in contacts.Index()) + { + table.AddRow( + (index + 1).ToString(CultureInfo.InvariantCulture), + contact.FullName, + contact.Email, + contact.PhoneNumber, + contact.CreatedOnUtc.ToString( + "d-MMM-yyyy HH:mm", + CultureInfo.InvariantCulture + ) + ); + } + return table; + } + + private static Table RenderOptions(GetContactsRequest request) + { + Table table = new() { ShowHeaders = false }; + + table.Border(TableBorder.None); + + table.AddColumn(new TableColumn("").Centered()); + + table.AddRow(RenderPagination(request)); + table.AddRow(RenderFeatures()); + + return table; + } + + private static Table RenderPagination(GetContactsRequest request) + { + Table table = new() { ShowRowSeparators = true }; + + table.AddColumn(new TableColumn("[deepskyblue1]Key[/]").Centered()); + table.AddColumn(new TableColumn("[deepskyblue1]Option[/]").Centered()); + table.AddColumn(new TableColumn($"[deepskyblue1]Value[/]").Centered()); + + table.AddRow("[white on grey] F [/]", "Search term", $"{request.SearchTerm}"); + table.AddRow("[white on grey] S [/]", "Page size", $"{request.PageSize}"); + table.AddRow( + "[white on grey] C [/]", + "Sort column", + $"{FormatSortColumn(request.SortColumn)}" + ); + table.AddRow( + "[white on grey] D [/]", + "Sort direction", + $"{FormatSortOrder(request.SortOrder)}" + ); + + return table; + + static string FormatSortColumn(string sortColumn) + { + return sortColumn switch + { + "first_name" => "First name", + "last_name" => "Last name", + "email" => "Email", + "phone_number" => "Phone number", + "created_on" => "Created", + _ => string.Empty, + }; + } + + static string FormatSortOrder(string sortOrder) + { + return sortOrder switch + { + "asc" => "Ascending", + "desc" => "Descending", + _ => string.Empty, + }; + } + } + + private static Table RenderFeatures() + { + Table table = new() { ShowRowSeparators = true, ShowHeaders = false }; + + table.AddColumn(new TableColumn("").Centered()); + table.AddColumn(new TableColumn("").Centered()); + + table.AddRow("[white on grey] A [/]", "Add contact"); + table.AddRow("[white on grey] R [/]", "Remove contact"); + table.AddRow("[white on grey] E [/]", "Edit contact"); + table.AddRow("[white on grey] P [/]", "Create report"); + + return table; + } +} diff --git a/frontend/Console/Contacts/ContactsService.cs b/frontend/Console/Contacts/ContactsService.cs new file mode 100644 index 0000000..abc22a9 --- /dev/null +++ b/frontend/Console/Contacts/ContactsService.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using Console.Contacts.Create; +using Console.Contacts.GenerateReport; +using Console.Contacts.Get; +using Console.Contacts.Update; +using Console.Core.Input; +using Refit; +using Spectre.Console; + +namespace Console.Contacts; + +internal sealed class ContactsService(IContactsClient contactsClient) +{ + public async Task GetContacts(GetContactsRequest request) + { + try + { + ApiResponse result = await contactsClient.GetContacts( + request + ); + + if (!result.IsSuccessful) + { + AnsiConsole.MarkupLineInterpolated( + CultureInfo.InvariantCulture, + $"[red]{result.Error.Message}[/]" + ); + UserInput.PromptAnyKeyToContinue(); + } + + return result.Content; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]There was an error: {ex.Message}[/]"); + UserInput.PromptAnyKeyToContinue(); + + return null; + } + } + + public async Task GenerateReport(GenerateContactsReportRequest request) + { + try + { + IApiResponse result = await contactsClient.GenerateContactsReport(request); + + if (!result.IsSuccessful) + { + AnsiConsole.MarkupLineInterpolated( + CultureInfo.InvariantCulture, + $"[red]{result.Error.Message}[/]" + ); + UserInput.PromptAnyKeyToContinue(); + return; + } + + AnsiConsole.MarkupLine("[green]Report generated successfully![/]"); + UserInput.PromptAnyKeyToContinue(); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]There was an error: {ex.Message}[/]"); + UserInput.PromptAnyKeyToContinue(); + } + } + + public async Task CreateContact(CreateContactRequest request) + { + try + { + IApiResponse result = await contactsClient.CreateContact(request); + + if (!result.IsSuccessful) + { + AnsiConsole.MarkupLineInterpolated( + CultureInfo.InvariantCulture, + $"[red]{result.Error.Message}[/]" + ); + UserInput.PromptAnyKeyToContinue(); + return; + } + + AnsiConsole.MarkupLine("[green]Contact created successfully![/]"); + UserInput.PromptAnyKeyToContinue(); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]There was an error: {ex.Message}[/]"); + UserInput.PromptAnyKeyToContinue(); + } + } + + public async Task DeleteContact(Guid id) + { + try + { + IApiResponse result = await contactsClient.DeleteContact(id); + + if (!result.IsSuccessful) + { + AnsiConsole.MarkupLineInterpolated( + CultureInfo.InvariantCulture, + $"[red]{result.Error.Message}[/]" + ); + UserInput.PromptAnyKeyToContinue(); + return; + } + + AnsiConsole.MarkupLine("[green]Contact deleted successfully![/]"); + UserInput.PromptAnyKeyToContinue(); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]There was an error: {ex.Message}[/]"); + UserInput.PromptAnyKeyToContinue(); + } + } + + public async Task UpdateContact(Guid id, UpdateContactRequest request) + { + try + { + IApiResponse result = await contactsClient.UpdateContact(id, request); + + if (!result.IsSuccessful) + { + AnsiConsole.MarkupLineInterpolated( + CultureInfo.InvariantCulture, + $"[red]{result.Error.Message}[/]" + ); + UserInput.PromptAnyKeyToContinue(); + return; + } + + AnsiConsole.MarkupLine("[green]Contact updated successfully![/]"); + UserInput.PromptAnyKeyToContinue(); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]There was an error: {ex.Message}[/]"); + UserInput.PromptAnyKeyToContinue(); + } + } +} diff --git a/frontend/Console/Contacts/ContactsView.cs b/frontend/Console/Contacts/ContactsView.cs new file mode 100644 index 0000000..71835ec --- /dev/null +++ b/frontend/Console/Contacts/ContactsView.cs @@ -0,0 +1,101 @@ +using Console.Contacts.Create; +using Console.Contacts.Delete; +using Console.Contacts.GenerateReport; +using Console.Contacts.Get; +using Console.Contacts.Update; +using Console.Core.Input; + +namespace Console.Contacts; + +internal sealed class ContactsView( + ContactsService contactsService, + CreateContact createContact, + DeleteContact deleteContact, + UpdateContact updateContact, + GenerateContactsReport generateContactsReport +) +{ + public async Task Display() + { + bool exit = false; + + ContactsViewState state = new(); + + while (!exit) + { + GetContactsRequest request = new( + state.SearchTerm, + state.Page, + state.PageSize, + state.SortColumn, + state.SortOrder + ); + + GetContactsResponse? response = await contactsService.GetContacts(request); + + if (response is null) + { + break; + } + + ContactsLayout.Display(response, request); + + ConsoleKeyInfo key = System.Console.ReadKey(true); + + await HandleKeyInput(key, state, response); + + if (key.Key == ConsoleKey.Escape) + { + exit = UserInput.ConfirmExit(); + } + } + } + + private async Task HandleKeyInput( + ConsoleKeyInfo key, + ContactsViewState state, + GetContactsResponse response + ) + { + switch (key.Key) + { + case ConsoleKey.LeftArrow when response.HasPreviousPage: + state.Page--; + break; + case ConsoleKey.RightArrow when response.HasNextPage: + state.Page++; + break; + case ConsoleKey.F: + state.SearchTerm = UserInput.PromptString( + "Search term:", + allowEmpty: true + ); + state.Page = 1; + break; + case ConsoleKey.D: + state.SortOrder = UserInput.PromptSortOrder(); + state.Page = 1; + break; + case ConsoleKey.C: + state.SortColumn = UserInput.PromptContactsSortColumn(); + state.Page = 1; + break; + case ConsoleKey.S: + state.PageSize = UserInput.PromptPageSize(); + state.Page = 1; + break; + case ConsoleKey.P: + await generateContactsReport.Handle(state, response.Items); + break; + case ConsoleKey.A: + await createContact.Handle(); + break; + case ConsoleKey.R: + await deleteContact.Handle(response.Items); + break; + case ConsoleKey.E: + await updateContact.Handle(response.Items); + break; + } + } +} diff --git a/frontend/Console/Contacts/ContactsViewState.cs b/frontend/Console/Contacts/ContactsViewState.cs new file mode 100644 index 0000000..e03261d --- /dev/null +++ b/frontend/Console/Contacts/ContactsViewState.cs @@ -0,0 +1,10 @@ +namespace Console.Contacts; + +internal sealed class ContactsViewState +{ + public string SearchTerm { get; set; } = string.Empty; + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 10; + public string SortColumn { get; set; } = "first_name"; + public string SortOrder { get; set; } = "asc"; +} diff --git a/frontend/Console/Contacts/Create/CreateContact.cs b/frontend/Console/Contacts/Create/CreateContact.cs new file mode 100644 index 0000000..ee55b15 --- /dev/null +++ b/frontend/Console/Contacts/Create/CreateContact.cs @@ -0,0 +1,55 @@ +using Console.Core.Input; +using Spectre.Console; + +namespace Console.Contacts.Create; + +internal sealed class CreateContact(ContactsService contactsService) +{ + public async Task Handle() + { + AnsiConsole.Clear(); + AnsiConsole.MarkupLine("[blue]Creating a new contact...[/]"); + + string firstName = UserInput.PromptString("First name: ", allowEmpty: false); + string lastName = UserInput.PromptString("Last name: ", allowEmpty: false); + string email = UserInput.PromptEmail("Email: ", allowEmpty: false); + string phoneNumber = UserInput.PromptPhoneNumber( + "Phone number: ", + allowEmpty: false + ); + + CreateContactRequest request = new(firstName, lastName, email, phoneNumber); + + RenderInformation(request); + + if ( + await AnsiConsole.ConfirmAsync( + "Are you sure you want to create a new contact?" + ) + ) + { + await contactsService.CreateContact(request); + } + } + + private static void RenderInformation(CreateContactRequest request) + { + Table table = new() + { + Title = new TableTitle("[blue]New contact[/]"), + ShowHeaders = false, + ShowRowSeparators = true, + }; + + table.AddColumn(new TableColumn("Property").Centered()); + table.AddColumn(new TableColumn("Value").Centered()); + + table.AddRow("[deepskyblue1]First name[/]", $"{request.FirstName}"); + table.AddRow("[deepskyblue1]Last name[/]", $"{request.LastName}"); + table.AddRow("[deepskyblue1]Email[/]", $"{request.Email}"); + table.AddRow("[deepskyblue1]Phone number[/]", $"{request.PhoneNumber}"); + + AnsiConsole.Clear(); + AnsiConsole.Write(table); + } +} diff --git a/frontend/Console/Contacts/Create/CreateContactRequest.cs b/frontend/Console/Contacts/Create/CreateContactRequest.cs new file mode 100644 index 0000000..dd8b2ee --- /dev/null +++ b/frontend/Console/Contacts/Create/CreateContactRequest.cs @@ -0,0 +1,8 @@ +namespace Console.Contacts.Create; + +internal sealed record CreateContactRequest( + string FirstName, + string LastName, + string Email, + string PhoneNumber +); diff --git a/frontend/Console/Contacts/Delete/DeleteContact.cs b/frontend/Console/Contacts/Delete/DeleteContact.cs new file mode 100644 index 0000000..8b33460 --- /dev/null +++ b/frontend/Console/Contacts/Delete/DeleteContact.cs @@ -0,0 +1,57 @@ +using Console.Core.Input; +using Spectre.Console; + +namespace Console.Contacts.Delete; + +internal sealed class DeleteContact(ContactsService contactsService) +{ + public async Task Handle(IReadOnlyCollection contacts) + { + if (contacts.Count == 0) + { + AnsiConsole.MarkupLine("[red]No contacts to remove![/]"); + UserInput.PromptAnyKeyToContinue(); + return; + } + + int id = UserInput.PromptPositiveInteger( + "Enter contact Id to remove:", + allowZero: false + ); + int index = UserInput.GetValidListIndex(id, contacts); + + Contact? contact = contacts.ElementAtOrDefault(index); + + if (contact is null) + { + AnsiConsole.MarkupLine("[red]Contact not found![/]"); + return; + } + + RenderInformation(contact); + + if ( + await AnsiConsole.ConfirmAsync( + $"Are you sure you want to delete this contact?" + ) + ) + { + await contactsService.DeleteContact(contact.Id); + } + } + + private static void RenderInformation(Contact contact) + { + Table table = new() { ShowHeaders = false, ShowRowSeparators = true }; + + table.AddColumn(new TableColumn("Property").Centered()); + table.AddColumn(new TableColumn("Value").Centered()); + + table.AddRow("[deepskyblue1]Name[/]", $"{contact.FullName}"); + table.AddRow("[deepskyblue1]Email[/]", $"{contact.Email}"); + table.AddRow("[deepskyblue1]Phone number[/]", $"{contact.PhoneNumber}"); + + AnsiConsole.Clear(); + AnsiConsole.Write(table); + } +} diff --git a/frontend/Console/Contacts/GenerateReport/GenerateContactsReport.cs b/frontend/Console/Contacts/GenerateReport/GenerateContactsReport.cs new file mode 100644 index 0000000..fa6e130 --- /dev/null +++ b/frontend/Console/Contacts/GenerateReport/GenerateContactsReport.cs @@ -0,0 +1,36 @@ +using Console.Core.Input; +using Spectre.Console; + +namespace Console.Contacts.GenerateReport; + +internal sealed class GenerateContactsReport(ContactsService contactsService) +{ + public async Task Handle( + ContactsViewState state, + IReadOnlyCollection contacts + ) + { + if (contacts.Count == 0) + { + AnsiConsole.MarkupLine( + "[red]Unable to generate a report with no contacts![/]" + ); + AnsiConsole.MarkupLine("[grey]Edit filters or create contacts.[/]"); + AnsiConsole.WriteLine(); + UserInput.PromptAnyKeyToContinue(); + return; + } + + GenerateContactsReportRequest contactsReportRequest = new( + state.SearchTerm, + state.SortColumn, + state.SortOrder + ); + + AnsiConsole.MarkupLine("Are you sure you want to generate a PDF report?"); + if (await AnsiConsole.ConfirmAsync("Current filters will apply.")) + { + await contactsService.GenerateReport(contactsReportRequest); + } + } +} diff --git a/frontend/Console/Contacts/GenerateReport/GenerateContactsReportRequest.cs b/frontend/Console/Contacts/GenerateReport/GenerateContactsReportRequest.cs new file mode 100644 index 0000000..e02fedc --- /dev/null +++ b/frontend/Console/Contacts/GenerateReport/GenerateContactsReportRequest.cs @@ -0,0 +1,7 @@ +namespace Console.Contacts.GenerateReport; + +internal sealed record GenerateContactsReportRequest( + string SearchTerm, + string SortColumn, + string SortOrder +); diff --git a/frontend/Console/Contacts/Get/GetContactsRequest.cs b/frontend/Console/Contacts/Get/GetContactsRequest.cs new file mode 100644 index 0000000..07ed5a7 --- /dev/null +++ b/frontend/Console/Contacts/Get/GetContactsRequest.cs @@ -0,0 +1,9 @@ +namespace Console.Contacts.Get; + +internal sealed record GetContactsRequest( + string SearchTerm, + int Page, + int PageSize, + string SortColumn, + string SortOrder +); diff --git a/frontend/Console/Contacts/Get/GetContactsResponse.cs b/frontend/Console/Contacts/Get/GetContactsResponse.cs new file mode 100644 index 0000000..f6494ca --- /dev/null +++ b/frontend/Console/Contacts/Get/GetContactsResponse.cs @@ -0,0 +1,11 @@ +namespace Console.Contacts.Get; + +internal sealed record GetContactsResponse( + int Page, + int PageSize, + int TotalCount, + int TotalPages, + bool HasNextPage, + bool HasPreviousPage, + IReadOnlyCollection Items +); diff --git a/frontend/Console/Contacts/IContactsClient.cs b/frontend/Console/Contacts/IContactsClient.cs new file mode 100644 index 0000000..120a77b --- /dev/null +++ b/frontend/Console/Contacts/IContactsClient.cs @@ -0,0 +1,25 @@ +using Console.Contacts.Create; +using Console.Contacts.GenerateReport; +using Console.Contacts.Get; +using Console.Contacts.Update; +using Refit; + +namespace Console.Contacts; + +internal interface IContactsClient +{ + [Get("/contacts")] + Task> GetContacts(GetContactsRequest request); + + [Get("/contacts/report")] + Task GenerateContactsReport(GenerateContactsReportRequest request); + + [Post("/contacts")] + Task CreateContact([Body] CreateContactRequest request); + + [Delete("/contacts/{id}")] + Task DeleteContact(Guid id); + + [Put("/contacts/{id}")] + Task UpdateContact(Guid id, [Body] UpdateContactRequest request); +} diff --git a/frontend/Console/Contacts/Update/UpdateContact.cs b/frontend/Console/Contacts/Update/UpdateContact.cs new file mode 100644 index 0000000..e78a4b8 --- /dev/null +++ b/frontend/Console/Contacts/Update/UpdateContact.cs @@ -0,0 +1,104 @@ +using Console.Core.Input; +using Spectre.Console; + +namespace Console.Contacts.Update; + +internal sealed class UpdateContact(ContactsService contactsService) +{ + public async Task Handle(IReadOnlyCollection contacts) + { + if (contacts.Count == 0) + { + AnsiConsole.MarkupLine("[red]No contacts to update![/]"); + UserInput.PromptAnyKeyToContinue(); + return; + } + + int id = UserInput.PromptPositiveInteger( + "Enter contact Id to edit:", + allowZero: false + ); + int index = UserInput.GetValidListIndex(id, contacts); + + Contact? contact = contacts.ElementAtOrDefault(index); + + if (contact is null) + { + AnsiConsole.MarkupLine("[red]Contact not found![/]"); + return; + } + + RenderInformation(contact); + AnsiConsole.MarkupLine("Enter new information. [grey]Leave empty to skip.[/]"); + + string firstName = UserInput.PromptString("First name:", allowEmpty: true); + string lastName = UserInput.PromptString("Last name:", allowEmpty: true); + string email = UserInput.PromptString("Email:", allowEmpty: true); + string phoneNumber = UserInput.PromptString("Phone number:", allowEmpty: true); + + UpdateContactRequest request = new( + string.IsNullOrWhiteSpace(firstName) ? contact.FirstName : firstName, + string.IsNullOrWhiteSpace(lastName) ? contact.LastName : lastName, + string.IsNullOrWhiteSpace(email) ? contact.Email : email, + string.IsNullOrWhiteSpace(phoneNumber) ? contact.PhoneNumber : phoneNumber + ); + + RenderUpdateInformation(contact, request); + + if (await AnsiConsole.ConfirmAsync("Are you sure you want to save the changes?")) + { + await contactsService.UpdateContact(contact.Id, request); + } + } + + private static void RenderInformation(Contact contact) + { + Table table = new() { ShowHeaders = false, ShowRowSeparators = true }; + + table.AddColumn(new TableColumn("Property").Centered()); + table.AddColumn(new TableColumn("Value").Centered()); + + table.AddRow("[deepskyblue1]Name[/]", $"{contact.FullName}"); + table.AddRow("[deepskyblue1]Email[/]", $"{contact.Email}"); + table.AddRow("[deepskyblue1]Phone number[/]", $"{contact.PhoneNumber}"); + + AnsiConsole.Clear(); + AnsiConsole.Write(table); + } + + private static void RenderUpdateInformation( + Contact contact, + UpdateContactRequest request + ) + { + Table table = new() { ShowRowSeparators = true }; + + table.AddColumn(new TableColumn("").Centered()); + table.AddColumn(new TableColumn("[green]New[/]").Centered()); + table.AddColumn(new TableColumn("[grey]Old[/]").Centered()); + + table.AddRow( + "[deepskyblue1]First name[/]", + $"{request.FirstName}", + $"[grey]{contact.FirstName}[/]" + ); + table.AddRow( + "[deepskyblue1]Last name[/]", + $"{request.LastName}", + $"[grey]{contact.LastName}[/]" + ); + table.AddRow( + "[deepskyblue1]Email[/]", + $"{request.Email}", + $"[grey]{contact.Email}[/]" + ); + table.AddRow( + "[deepskyblue1]Phone number[/]", + $"{request.PhoneNumber}", + $"[grey]{contact.PhoneNumber}[/]" + ); + + AnsiConsole.Clear(); + AnsiConsole.Write(table); + } +} diff --git a/frontend/Console/Contacts/Update/UpdateContactRequest.cs b/frontend/Console/Contacts/Update/UpdateContactRequest.cs new file mode 100644 index 0000000..ae7bed8 --- /dev/null +++ b/frontend/Console/Contacts/Update/UpdateContactRequest.cs @@ -0,0 +1,8 @@ +namespace Console.Contacts.Update; + +internal sealed record UpdateContactRequest( + string FirstName, + string LastName, + string Email, + string PhoneNumber +); diff --git a/frontend/Console/Core/DependencyInjection.cs b/frontend/Console/Core/DependencyInjection.cs new file mode 100644 index 0000000..5aae01d --- /dev/null +++ b/frontend/Console/Core/DependencyInjection.cs @@ -0,0 +1,37 @@ +using Console.Contacts; +using Console.Contacts.Create; +using Console.Contacts.Delete; +using Console.Contacts.GenerateReport; +using Console.Contacts.Update; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Refit; + +namespace Console.Core; + +internal static class DependencyInjection +{ + public static void AddConsoleServices( + this IServiceCollection services, + IConfiguration configuration + ) + { + services.AddLogging(b => b.ClearProviders()); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + string apiAdress = + configuration.GetValue("ApiSettings:PhoneForgeApiAddress") + ?? throw new InvalidOperationException("Missing connection string."); + + services + .AddRefitClient() + .ConfigureHttpClient(c => c.BaseAddress = new Uri(apiAdress)); + } +} diff --git a/frontend/Console/Core/Enums/ContactsSortColumn.cs b/frontend/Console/Core/Enums/ContactsSortColumn.cs new file mode 100644 index 0000000..6fa63d2 --- /dev/null +++ b/frontend/Console/Core/Enums/ContactsSortColumn.cs @@ -0,0 +1,10 @@ +namespace Console.Core.Enums; + +internal enum ContactsSortColumn +{ + FirstName, + LastName, + Email, + PhoneNumber, + CreatedOn, +} diff --git a/frontend/Console/Core/Enums/PageSize.cs b/frontend/Console/Core/Enums/PageSize.cs new file mode 100644 index 0000000..23fb390 --- /dev/null +++ b/frontend/Console/Core/Enums/PageSize.cs @@ -0,0 +1,8 @@ +namespace Console.Core.Enums; + +internal enum PageSize +{ + Ten, + Twenty, + Thirty, +} diff --git a/frontend/Console/Core/Enums/SortOrder.cs b/frontend/Console/Core/Enums/SortOrder.cs new file mode 100644 index 0000000..9c5c0cf --- /dev/null +++ b/frontend/Console/Core/Enums/SortOrder.cs @@ -0,0 +1,7 @@ +namespace Console.Core.Enums; + +internal enum SortOrder +{ + Ascending, + Descending, +} diff --git a/frontend/Console/Core/Input/UserInput.cs b/frontend/Console/Core/Input/UserInput.cs new file mode 100644 index 0000000..1901e78 --- /dev/null +++ b/frontend/Console/Core/Input/UserInput.cs @@ -0,0 +1,167 @@ +using Console.Core.Enums; +using Console.Core.Validation; +using Spectre.Console; + +namespace Console.Core.Input; + +internal static class UserInput +{ + public static void PromptAnyKeyToContinue() + { + AnsiConsole.Write("Press any key to continue..."); + System.Console.ReadKey(); + } + + public static bool ConfirmExit() + { + if (AnsiConsole.Confirm("Are you sure you want to exit?")) + { + AnsiConsole.WriteLine("Goodbye!"); + return true; + } + return false; + } + + public static string PromptString(string displayMessage, bool allowEmpty) + { + TextPrompt prompt = new(displayMessage); + + if (allowEmpty) + { + prompt.AllowEmpty(); + } + + return AnsiConsole.Prompt(prompt); + } + + public static string PromptEmail(string displayMessage, bool allowEmpty) + { + TextPrompt prompt = new(displayMessage); + + if (allowEmpty) + { + prompt.AllowEmpty(); + } + + prompt.Validate(InputValidation.IsValidEmail); + + return AnsiConsole.Prompt(prompt); + } + + public static string PromptPhoneNumber(string displayMessage, bool allowEmpty) + { + TextPrompt prompt = new(displayMessage); + + if (allowEmpty) + { + prompt.AllowEmpty(); + } + + prompt.Validate(InputValidation.IsValidPhoneNumber); + + return AnsiConsole.Prompt(prompt); + } + + public static int PromptPositiveInteger(string displayMessage, bool allowZero) + { + TextPrompt prompt = new(displayMessage); + prompt.ValidationErrorMessage("[red]Input must a positive integer![/]"); + + if (allowZero) + { + prompt.Validate(InputValidation.IsGreaterThanOrEqualToZero); + } + + prompt.Validate(InputValidation.IsGreaterThanZero); + + return AnsiConsole.Prompt(prompt); + } + + public static int GetValidListIndex(int input, IReadOnlyCollection list) + where T : notnull + { + while (input > list.Count) + { + AnsiConsole.MarkupLine("[red]Invalid Id![/]"); + input = PromptPositiveInteger("Enter contact Id:", allowZero: false); + } + return input - 1; + } + + public static string PromptSortOrder() + { + SortOrder selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select sorting order:") + .AddChoices(Enum.GetValues()) + ); + + return selection switch + { + SortOrder.Ascending => "asc", + SortOrder.Descending => "desc", + _ => "Unknown sort order option", + }; + } + + public static string PromptContactsSortColumn() + { + ContactsSortColumn selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select sorting column:") + .UseConverter(ConvertSortColumnOptions) + .AddChoices(Enum.GetValues()) + ); + + return selection switch + { + ContactsSortColumn.FirstName => "first_name", + ContactsSortColumn.LastName => "last_name", + ContactsSortColumn.Email => "email", + ContactsSortColumn.PhoneNumber => "phone_number", + ContactsSortColumn.CreatedOn => "created_on", + _ => "Unkown sort column option.", + }; + + static string ConvertSortColumnOptions(ContactsSortColumn options) + { + return options switch + { + ContactsSortColumn.FirstName => "First name", + ContactsSortColumn.LastName => "Last name", + ContactsSortColumn.Email => "Email", + ContactsSortColumn.PhoneNumber => "Phone number", + ContactsSortColumn.CreatedOn => "Date created", + _ => "Unkown sort column option.", + }; + } + } + + public static int PromptPageSize() + { + PageSize selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select page size:") + .UseConverter(ConvertPageSizeOptions) + .AddChoices(Enum.GetValues()) + ); + + return selection switch + { + PageSize.Ten => 10, + PageSize.Twenty => 20, + PageSize.Thirty => 30, + _ => 10, + }; + static string ConvertPageSizeOptions(PageSize options) + { + return options switch + { + PageSize.Ten => "10", + PageSize.Twenty => "20", + PageSize.Thirty => "30", + _ => "Unkown page size option.", + }; + } + } +} diff --git a/frontend/Console/Core/Validation/InputValidation.cs b/frontend/Console/Core/Validation/InputValidation.cs new file mode 100644 index 0000000..4a9dfba --- /dev/null +++ b/frontend/Console/Core/Validation/InputValidation.cs @@ -0,0 +1,58 @@ +using Spectre.Console; + +namespace Console.Core.Validation; + +internal static class InputValidation +{ + public static ValidationResult IsGreaterThanOrEqualToZero(int input) + { + return input switch + { + < 0 => ValidationResult.Error( + "[red]Input must be greater than or equal to zero![/]" + ), + _ => ValidationResult.Success(), + }; + } + + public static ValidationResult IsGreaterThanZero(int input) + { + return input switch + { + < 1 => ValidationResult.Error("[red]Input must be greater than 0![/]"), + _ => ValidationResult.Success(), + }; + } + + public static ValidationResult IsValidEmail(string input) + { + if (!input.Contains('@')) + { + return ValidationResult.Error("[red]Invalid email format![/]"); + } + + return ValidationResult.Success(); + } + + public static ValidationResult IsValidPhoneNumber(string input) + { + if (!input.All(char.IsDigit)) + { + return ValidationResult.Error( + "[red]Invalid phone number! Only numbers are allowed.[/]" + ); + } + + if (input.Length < 6) + { + return ValidationResult.Error("[red]Minimum phone number length is 6![/]"); + } + + if (input.Length > 20) + { + return ValidationResult.Error("[red]Maximum phone number length is 20![/]"); + } + + return ValidationResult.Success(); + } +} diff --git a/frontend/Console/Program.cs b/frontend/Console/Program.cs index 3751555..04bb7f7 100644 --- a/frontend/Console/Program.cs +++ b/frontend/Console/Program.cs @@ -1,2 +1,14 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using Console.Contacts; +using Console.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + +builder.Services.AddConsoleServices(builder.Configuration); + +using IHost host = builder.Build(); + +ContactsView contactsView = host.Services.GetRequiredService(); + +await contactsView.Display(); diff --git a/frontend/Console/appsettings.json b/frontend/Console/appsettings.json new file mode 100644 index 0000000..6957519 --- /dev/null +++ b/frontend/Console/appsettings.json @@ -0,0 +1,6 @@ +{ + "ApiSettings": { + "PhoneForgeApiAddress": "http://localhost:5111/api/v1" + } +} + From a68baab020da0ef04b43b8e4db711ff6596b05df Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:54:03 +0100 Subject: [PATCH 43/46] refactor: move database check before excel parsing when seeding contacts (#75) --- .../Database/Seeding/SeedingService.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/Infrastructure/Database/Seeding/SeedingService.cs b/backend/Infrastructure/Database/Seeding/SeedingService.cs index af642b5..33a36a8 100644 --- a/backend/Infrastructure/Database/Seeding/SeedingService.cs +++ b/backend/Infrastructure/Database/Seeding/SeedingService.cs @@ -36,23 +36,23 @@ public async Task SeedContacts() { logger.LogInformation("Seeding contacts to the database."); - Result> getContactsResult = await excelService.GetContacts(); - - if (getContactsResult.IsFailure) + if (await context.Contacts.AnyAsync()) { + logger.LogWarning( + "Contacts already exists in the database. Aborting process." + ); return; } - List contacts = getContactsResult.Value; + Result> getContactsResult = await excelService.GetContacts(); - if (await context.Contacts.AnyAsync()) + if (getContactsResult.IsFailure) { - logger.LogWarning( - "Contacts already exists in the database. Aborting process." - ); return; } + List contacts = getContactsResult.Value; + context.AddRange(contacts); await context.SaveChangesAsync(); From 0333a4c9c2cf6fe4d540d09bf02781f46a0f31af Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:55:29 +0100 Subject: [PATCH 44/46] build: enable repeatable package restores in workflows (#77) * add RestorePackagesWithLockFile property * add package caching to workflows --- .github/workflows/build-validation.yml | 9 +- .github/workflows/test-validation.yml | 9 +- Directory.Build.props | 1 + backend/Application/packages.lock.json | 124 +++ backend/Domain/packages.lock.json | 13 + backend/Infrastructure/packages.lock.json | 401 +++++++++ backend/WebApi/packages.lock.json | 700 ++++++++++++++++ frontend/Console/packages.lock.json | 332 ++++++++ tests/ArchitectureTests/packages.lock.json | 726 ++++++++++++++++ tests/IntegrationTests/packages.lock.json | 921 +++++++++++++++++++++ tests/TestData/packages.lock.json | 621 ++++++++++++++ tests/UnitTests/packages.lock.json | 725 ++++++++++++++++ 12 files changed, 4580 insertions(+), 2 deletions(-) create mode 100644 backend/Application/packages.lock.json create mode 100644 backend/Domain/packages.lock.json create mode 100644 backend/Infrastructure/packages.lock.json create mode 100644 backend/WebApi/packages.lock.json create mode 100644 frontend/Console/packages.lock.json create mode 100644 tests/ArchitectureTests/packages.lock.json create mode 100644 tests/IntegrationTests/packages.lock.json create mode 100644 tests/TestData/packages.lock.json create mode 100644 tests/UnitTests/packages.lock.json diff --git a/.github/workflows/build-validation.yml b/.github/workflows/build-validation.yml index 793cd3e..74236f5 100644 --- a/.github/workflows/build-validation.yml +++ b/.github/workflows/build-validation.yml @@ -5,6 +5,11 @@ on: branches: - main - develop + paths-ignore: + - "**/*.md" + - "**/*.gitignore" + - "**/*.gitattributes" + workflow_dispatch: env: DOTNET_VERSION: "10.0.x" # The .NET SDK version to use @@ -20,9 +25,11 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + cache: true + cache-dependency-path: "**/packages.lock.json" - name: Install dependencies - run: dotnet restore + run: dotnet restore --locked-mode - name: Build run: dotnet build --configuration Release --no-restore diff --git a/.github/workflows/test-validation.yml b/.github/workflows/test-validation.yml index a348eb6..b3d58c2 100644 --- a/.github/workflows/test-validation.yml +++ b/.github/workflows/test-validation.yml @@ -5,6 +5,11 @@ on: branches: - main - develop + paths-ignore: + - "**/*.md" + - "**/*.gitignore" + - "**/*.gitattributes" + workflow_dispatch: env: DOTNET_VERSION: "10.0.x" # The .NET SDK version to use @@ -20,9 +25,11 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} + cache: true + cache-dependency-path: "**/packages.lock.json" - name: Install dependencies - run: dotnet restore + run: dotnet restore --locked-mode - name: Build run: dotnet build --configuration Release --no-restore diff --git a/Directory.Build.props b/Directory.Build.props index f51c3df..6b3c0ef 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,7 @@ net10.0 enable enable + true latest-Recommended true true diff --git a/backend/Application/packages.lock.json b/backend/Application/packages.lock.json new file mode 100644 index 0000000..bbacc28 --- /dev/null +++ b/backend/Application/packages.lock.json @@ -0,0 +1,124 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "hHa2amRjMyBLUH/KTML6FgIAhZ0VFYkhCKwWEax0rO6iNeM1P5MflyeQLE5dniSIOZHc3Oqyv5UIyTFO4e1Auw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Scrutor": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "C+TT9k7f1GQ8agOfV512K9iwrzi76RXVSDiLx+iWC9pz3QhEpSF1Dyk+FpVvd8ULQ+rqymfM8KQ7g48ttQVyMg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TxHQq0kn0tpYs2ljeRl8jtmWk720B0nteqI6mAZM77HWJpYT9Zj8SkkBBlj8K3Yeq18a6NBjz6YutE+shEk4Ag==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "RFYJR7APio/BiqdQunRq6DB+nDB6nc2qhHr77mlvZ0q0BT8PubMXN7XicmfzCbrDE/dzhBnUKBRXLTcqUiZDGg==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "domain": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/backend/Domain/packages.lock.json b/backend/Domain/packages.lock.json new file mode 100644 index 0000000..f6639bd --- /dev/null +++ b/backend/Domain/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + } + } + } +} \ No newline at end of file diff --git a/backend/Infrastructure/packages.lock.json b/backend/Infrastructure/packages.lock.json new file mode 100644 index 0000000..d084bac --- /dev/null +++ b/backend/Infrastructure/packages.lock.json @@ -0,0 +1,401 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "ClosedXML": { + "type": "Direct", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "U0hAdnYyPvF7TqHMFloxrS7pmozab79tFFF4c/bgPtqeelUs7ILpUd3r3c7C0a/DXsUZb3k1n4Pf7Q2LMyMQOg==", + "dependencies": { + "ClosedXML.Parser": "2.0.0", + "DocumentFormat.OpenXml": "[3.1.1, 4.0.0)", + "ExcelNumberFormat": "1.1.0", + "RBush.Signed": "4.0.0", + "SixLabors.Fonts": "1.0.0" + } + }, + "Handlebars.Net": { + "type": "Direct", + "requested": "[2.1.6, )", + "resolved": "2.1.6", + "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" + }, + "Microsoft.EntityFrameworkCore.SqlServer": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "9ip+r8Wtu0qNtFLYnQC3bkhQAsINIyo8XWYJHCVY22BFLxIT4rPxZPkbto56SjCnkwxS6nnbFZTU6KuAO2Nu1g==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.1", + "Microsoft.EntityFrameworkCore.Relational": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "PuppeteerSharp": { + "type": "Direct", + "requested": "[20.2.5, )", + "resolved": "20.2.5", + "contentHash": "w9/oEME3spddIKjh8C3sMxQziJe6lcdsTtQCnG3GevupZYQPx243dz1znh9YIE9otulXa9lg+Pnf/SMUCq4/Gw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.47.1", + "contentHash": "oPcncSsDHuxB8SC522z47xbp2+ttkcKv2YZ90KXhRKN0YQd2+7l1UURT9EBzUNEXtkLZUOAB5xbByMTrYRh3yA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.5.1", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.14.2", + "contentHash": "YhNMwOTwT+I2wIcJKSdP0ADyB2aK+JaYWZxO8LSRDm5w77LFr0ykR9xmt2ZV5T1gaI7xU6iNFIh/yW1dAlpddQ==", + "dependencies": { + "Azure.Core": "1.46.1", + "Microsoft.Identity.Client": "4.73.1", + "Microsoft.Identity.Client.Extensions.Msal": "4.73.1" + } + }, + "ClosedXML.Parser": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "ngTqjYreDYNytG1W5d3ewHsw0ukmmrgV7EKnS4/40rXoYZGt07jrBvo+N+GxT49rcageUMUiprV0jYT4nwVBHQ==" + }, + "DocumentFormat.OpenXml": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "2z9QBzeTLNNKWM9SaOSDMegfQk/7hDuElOsmF77pKZMkFRP/GHA/W/4yOAQD9kn15N/FsFxHn3QVYkatuZghiA==", + "dependencies": { + "DocumentFormat.OpenXml.Framework": "3.1.1" + } + }, + "DocumentFormat.OpenXml.Framework": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "6APEp/ElZV58S/4v8mf4Ke3ONEDORs64MqdD64Z7wWpcHANB9oovQsGIwtqjnKihulOj7T0a6IxHIHOfMqKOng==", + "dependencies": { + "System.IO.Packaging": "8.0.1" + } + }, + "ExcelNumberFormat": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "R3BVHPs9O+RkExbZYTGT0+9HLbi8ZrNij1Yziyw6znd3J7P3uoIR07uwTLGOogtz1p6+0sna66eBoXu7tBiVQA==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "YgZYAWzyNuPVtPq6WNm0bqOWNjYaWgl5mBWTGZyNoXitYBUYSp6iUB9AwK0V1mo793qRJUXz2t6UZrWITZSvuQ==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "syGQmIUPAYYHAHyTD8FCkTNThpQWvoA7crnIQRMfp8dyB5A2cWU3fQexlRTFkVmV7S0TjVmthi0LJEFVjHo8AQ==", + "dependencies": { + "Azure.Core": "1.47.1", + "Azure.Identity": "1.14.2", + "Microsoft.Bcl.Cryptography": "9.0.4", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.4", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "9.0.4", + "System.Security.Cryptography.Pkcs": "9.0.4" + } + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "C+TT9k7f1GQ8agOfV512K9iwrzi76RXVSDiLx+iWC9pz3QhEpSF1Dyk+FpVvd8ULQ+rqymfM8KQ7g48ttQVyMg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TxHQq0kn0tpYs2ljeRl8jtmWk720B0nteqI6mAZM77HWJpYT9Zj8SkkBBlj8K3Yeq18a6NBjz6YutE+shEk4Ag==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A3MX1ee7RDxWCUdx/KqP+74fbksz0UIhkVZh56YHvbPkEKsffCXgHU3LGkRDwqR/MrBNWLCWC/IVX79tzM30ZA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "RFYJR7APio/BiqdQunRq6DB+nDB6nc2qhHr77mlvZ0q0BT8PubMXN7XicmfzCbrDE/dzhBnUKBRXLTcqUiZDGg==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "NnDLS8QwYqO5ZZecL2oioi1LUqjh5Ewk4bMLzbgiXJbQmZhDLtKwLxL3DpGMlQAJ2G4KgEnvGPKa+OOgffeJbw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "xDztAiV2F0wI0W8FLKv5cbaBefyLD6JVaAsvgSN7bjWNCzGYzHbcOEIP5s4TJXUpQzMfUyBsFl1mC6Zmgpz0PQ==", + "dependencies": { + "Microsoft.Identity.Client": "4.73.1", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "RBush.Signed": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "aP5KQxL5RnFNGW1f0euYVBfCatkLw5iEzMRJcXKq8LWWP4Cp3+qoSq1tDDL2vvJ2rM0ychmVMa2VaEKLS6uX4w==" + }, + "SixLabors.Fonts": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "LFQsCZlV0xlUyXAOMUo5kkSl+8zAQXXbbdwWchtk0B4o7zotZhQsQOcJUELGHdfPfm/xDAsz6hONAuV25bJaAg==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.5.1", + "contentHash": "k2jKSO0X45IqhVOT9iQB4xralNN9foRQsRvXBTyRpAVxyzCJlG895T9qYrQWbcJ6OQXxOouJQ37x5nZH5XKK+A==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "dvjqKp+2LpGid6phzrdrS/2mmEPxFl3jE1+L7614q4ZChKbLJCpHXg6sBILlCCED1t//EE+un/UdAetzIMpqnw==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.4", + "System.Security.Cryptography.ProtectedData": "9.0.4" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "KYkIOAvPexQOLDxPO2g0BVoWInnQhPpkFzRqvNrNrMhVT6kqhVr0zEb6KCHlptLFukxnZrjuMVAnxK7pOGUYrw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "cUFTcMlz/Qw9s90b2wnWSCvHdjv51Bau9FQqhsr4TlwSe1OX+7SoXUqphis5G74MLOvMOCghxPPlEqOdCrVVGA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "o94k2RKuAce3GeDMlUvIXlhVa1kWpJw95E6C9LwW0KlG0nj5+SgCiIxJ2Eroqb9sLtG1mEMbFttZIBZ13EJPvQ==" + }, + "application": { + "type": "Project", + "dependencies": { + "Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.0, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.0, )" + } + }, + "domain": { + "type": "Project" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "hHa2amRjMyBLUH/KTML6FgIAhZ0VFYkhCKwWEax0rO6iNeM1P5MflyeQLE5dniSIOZHc3Oqyv5UIyTFO4e1Auw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + } + } + } +} \ No newline at end of file diff --git a/backend/WebApi/packages.lock.json b/backend/WebApi/packages.lock.json new file mode 100644 index 0000000..d245387 --- /dev/null +++ b/backend/WebApi/packages.lock.json @@ -0,0 +1,700 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Asp.Versioning.Http": { + "type": "Direct", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "Xu4xF62Cu9JqYi/CTa2TiK5kyHoa4EluPynj/bPFWDmlTIPzuJQbBI5RgFYVRFHjFVvWMoA77acRaFu7i7Wzqg==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "Direct", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "a90gW/4TF/14Bjiwg9LqNtdKGC4G3gu02+uynq3bCISfQm48km5chny4Yg5J4hixQPJUwwJJ9Do1G+jM8L9h3g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.0" + } + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "Direct", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "0aqIF1t+sA2T62LIeMtXGSiaV7keGQaJnvwwmu+htQdjCaKYARfXAeqp4nHH9y2etpilyZ/tnQzZg4Ilmo/c4Q==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Tools": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "Gx6AWCf1CsBHTbvVVWBYBKp6hrwZt0IgBigoBPaybwOBG2EFIfZc7NP2BoR6r56YuuikbPIzCuvQuFYNnX43+A==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Design": "10.0.0" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.1-dev-02315, )", + "resolved": "7.0.1-dev-02315", + "contentHash": "LuwBs0Hrr7ptVY/u+h+GJ2Of0+Uv3JiISD3ipiuZYnsy+bqeHEC1LQPVTPW9MgjY3tg3B6HObVbL84qGBcbdBA==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==" + }, + "Asp.Versioning.Mvc": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "BMAJM2sGsTUw5FQ9upKQt6GFoldWksePgGpYjl56WSRvIuE3UxKZh0gAL+wDTIfLshUZm97VCVxlOGyrcjWz9Q==", + "dependencies": { + "Asp.Versioning.Http": "8.1.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.47.1", + "contentHash": "oPcncSsDHuxB8SC522z47xbp2+ttkcKv2YZ90KXhRKN0YQd2+7l1UURT9EBzUNEXtkLZUOAB5xbByMTrYRh3yA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.5.1", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.14.2", + "contentHash": "YhNMwOTwT+I2wIcJKSdP0ADyB2aK+JaYWZxO8LSRDm5w77LFr0ykR9xmt2ZV5T1gaI7xU6iNFIh/yW1dAlpddQ==", + "dependencies": { + "Azure.Core": "1.46.1", + "Microsoft.Identity.Client": "4.73.1", + "Microsoft.Identity.Client.Extensions.Msal": "4.73.1" + } + }, + "ClosedXML.Parser": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "ngTqjYreDYNytG1W5d3ewHsw0ukmmrgV7EKnS4/40rXoYZGt07jrBvo+N+GxT49rcageUMUiprV0jYT4nwVBHQ==" + }, + "DocumentFormat.OpenXml": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "2z9QBzeTLNNKWM9SaOSDMegfQk/7hDuElOsmF77pKZMkFRP/GHA/W/4yOAQD9kn15N/FsFxHn3QVYkatuZghiA==", + "dependencies": { + "DocumentFormat.OpenXml.Framework": "3.1.1" + } + }, + "DocumentFormat.OpenXml.Framework": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "6APEp/ElZV58S/4v8mf4Ke3ONEDORs64MqdD64Z7wWpcHANB9oovQsGIwtqjnKihulOj7T0a6IxHIHOfMqKOng==", + "dependencies": { + "System.IO.Packaging": "8.0.1" + } + }, + "ExcelNumberFormat": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "R3BVHPs9O+RkExbZYTGT0+9HLbi8ZrNij1Yziyw6znd3J7P3uoIR07uwTLGOogtz1p6+0sna66eBoXu7tBiVQA==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "YgZYAWzyNuPVtPq6WNm0bqOWNjYaWgl5mBWTGZyNoXitYBUYSp6iUB9AwK0V1mo793qRJUXz2t6UZrWITZSvuQ==" + }, + "Microsoft.Build": { + "type": "Transitive", + "resolved": "17.7.2", + "contentHash": "AmWnumxsMiRycFfE3kq/XnFFTAoPpCWl3UuiKQWCa5Z0+hBKVoiydzS2iXJGd3x+jry+qaTR9GzoezjV9NFT5A==", + "dependencies": { + "Microsoft.Build.Framework": "17.7.2", + "Microsoft.NET.StringTools": "17.7.2", + "System.Configuration.ConfigurationManager": "7.0.0", + "System.Reflection.MetadataLoadContext": "7.0.0", + "System.Security.Permissions": "7.0.0" + } + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "17.14.28", + "contentHash": "wRcyTzGV0LRAtFdrddtioh59Ky4/zbvyraP0cQkDzRSRkhgAQb0K88D/JNC6VHLIXanRi3mtV1jU0uQkBwmiVg==" + }, + "Microsoft.Build.Tasks.Core": { + "type": "Transitive", + "resolved": "17.14.28", + "contentHash": "jk3O0tXp9QWPXhLJ7Pl8wm/eGtGgA1++vwHGWEmnwMU6eP//ghtcCUpQh9CQMwEKGDnH0aJf285V1s8yiSlKfQ==", + "dependencies": { + "Microsoft.Build.Framework": "17.14.28", + "Microsoft.Build.Utilities.Core": "17.14.28", + "Microsoft.NET.StringTools": "17.14.28", + "System.CodeDom": "9.0.0", + "System.Configuration.ConfigurationManager": "9.0.0", + "System.Formats.Nrbf": "9.0.0", + "System.Resources.Extensions": "9.0.0", + "System.Security.Cryptography.Pkcs": "9.0.0", + "System.Security.Cryptography.ProtectedData": "9.0.0" + } + }, + "Microsoft.Build.Utilities.Core": { + "type": "Transitive", + "resolved": "17.14.28", + "contentHash": "rhSdPo8QfLXXWM+rY0x0z1G4KK4ZhMoIbHROyDj8MUBFab9nvHR0NaMnjzOgXldhmD2zi2ir8d6xCatNzlhF5g==", + "dependencies": { + "Microsoft.Build.Framework": "17.14.28", + "Microsoft.NET.StringTools": "17.14.28", + "System.Configuration.ConfigurationManager": "9.0.0", + "System.Security.Cryptography.ProtectedData": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "QkgCEM4qJo6gdtblXtNgHqtykS61fxW+820hx5JN6n9DD4mQtqNB+6fPeJ3GQWg6jkkGz6oG9yZq7H3Gf0zwYw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[4.14.0]", + "Microsoft.CodeAnalysis.Common": "[4.14.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.14.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "wNVK9JrqjqDC/WgBUFV6henDfrW87NPfo98nzah/+M/G1D6sBOPtXwqce3UQNn+6AjTnmkHYN1WV9XmTlPemTw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "YU7Sguzm1Cuhi2U6S0DRKcVpqAdBd2QmatpyE0KqYMJogJ9E27KHOWGUzAOjsyjAM7sNaUk+a8VPz24knDseFw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build": "17.7.2", + "Microsoft.Build.Framework": "17.7.2", + "Microsoft.Build.Tasks.Core": "17.7.2", + "Microsoft.Build.Utilities.Core": "17.7.2", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.14.0]", + "Newtonsoft.Json": "13.0.3", + "System.CodeDom": "7.0.0", + "System.Composition": "9.0.0", + "System.Configuration.ConfigurationManager": "9.0.0", + "System.Resources.Extensions": "9.0.0", + "System.Security.Cryptography.Pkcs": "7.0.2", + "System.Security.Cryptography.ProtectedData": "9.0.0", + "System.Security.Permissions": "9.0.0", + "System.Windows.Extensions": "9.0.0" + } + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "syGQmIUPAYYHAHyTD8FCkTNThpQWvoA7crnIQRMfp8dyB5A2cWU3fQexlRTFkVmV7S0TjVmthi0LJEFVjHo8AQ==", + "dependencies": { + "Azure.Core": "1.47.1", + "Azure.Identity": "1.14.2", + "Microsoft.Bcl.Cryptography": "9.0.4", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "9.0.4", + "System.Security.Cryptography.Pkcs": "9.0.4" + } + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "C+TT9k7f1GQ8agOfV512K9iwrzi76RXVSDiLx+iWC9pz3QhEpSF1Dyk+FpVvd8ULQ+rqymfM8KQ7g48ttQVyMg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TxHQq0kn0tpYs2ljeRl8jtmWk720B0nteqI6mAZM77HWJpYT9Zj8SkkBBlj8K3Yeq18a6NBjz6YutE+shEk4Ag==" + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "R7BeFniEpBrHw8kKVtWiMG4PRAwJ4K1RZoQWB32Ak8ws3uvYH98DVp9Y2UBUgbwY5lR9wPlrxp7P3GGDQ7LUSQ==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.14.28", + "Microsoft.Build.Tasks.Core": "17.14.28", + "Microsoft.Build.Utilities.Core": "17.14.28", + "Microsoft.CodeAnalysis.CSharp": "4.14.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "4.14.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "4.14.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A3MX1ee7RDxWCUdx/KqP+74fbksz0UIhkVZh56YHvbPkEKsffCXgHU3LGkRDwqR/MrBNWLCWC/IVX79tzM30ZA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "RFYJR7APio/BiqdQunRq6DB+nDB6nc2qhHr77mlvZ0q0BT8PubMXN7XicmfzCbrDE/dzhBnUKBRXLTcqUiZDGg==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "NnDLS8QwYqO5ZZecL2oioi1LUqjh5Ewk4bMLzbgiXJbQmZhDLtKwLxL3DpGMlQAJ2G4KgEnvGPKa+OOgffeJbw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "xDztAiV2F0wI0W8FLKv5cbaBefyLD6JVaAsvgSN7bjWNCzGYzHbcOEIP5s4TJXUpQzMfUyBsFl1mC6Zmgpz0PQ==", + "dependencies": { + "Microsoft.Identity.Client": "4.73.1", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.NET.StringTools": { + "type": "Transitive", + "resolved": "17.14.28", + "contentHash": "DMIeWDlxe0Wz0DIhJZ2FMoGQAN2yrGZOi5jjFhRYHWR5ONd0CS6IpAHlRnA7uA/5BF+BADvgsETxW2XrPiFc1A==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "RBush.Signed": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "aP5KQxL5RnFNGW1f0euYVBfCatkLw5iEzMRJcXKq8LWWP4Cp3+qoSq1tDDL2vvJ2rM0ychmVMa2VaEKLS6uX4w==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SixLabors.Fonts": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "LFQsCZlV0xlUyXAOMUo5kkSl+8zAQXXbbdwWchtk0B4o7zotZhQsQOcJUELGHdfPfm/xDAsz6hONAuV25bJaAg==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.5.1", + "contentHash": "k2jKSO0X45IqhVOT9iQB4xralNN9foRQsRvXBTyRpAVxyzCJlG895T9qYrQWbcJ6OQXxOouJQ37x5nZH5XKK+A==", + "dependencies": { + "System.Memory.Data": "8.0.1" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "oTE5IfuMoET8yaZP/vdvy9xO47guAv/rOhe4DODuFBN3ySprcQOlXqO3j+e/H/YpKKR5sglrxRaZ2HYOhNJrqA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "dvjqKp+2LpGid6phzrdrS/2mmEPxFl3jE1+L7614q4ZChKbLJCpHXg6sBILlCCED1t//EE+un/UdAetzIMpqnw==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "9.0.4" + } + }, + "System.Formats.Nrbf": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "F/6tNE+ckmdFeSQAyQo26bQOqfPFKEfZcuqnp4kBE6/7jP26diP+QTHCJJ6vpEfaY6bLy+hBLiIQUSxSmNwLkA==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "KYkIOAvPexQOLDxPO2g0BVoWInnQhPpkFzRqvNrNrMhVT6kqhVr0zEb6KCHlptLFukxnZrjuMVAnxK7pOGUYrw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Reflection.MetadataLoadContext": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "z9PvtMJra5hK8n+g0wmPtaG7HQRZpTmIPRw5Z0LEemlcdQMHuTD5D7OAY/fZuuz1L9db++QOcDF0gJTLpbMtZQ==" + }, + "System.Resources.Extensions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "tvhuT1D2OwPROdL1kRWtaTJliQo0WdyhvwDpd8RM997G7m3Hya5nhbYhNTS75x6Vu+ypSOgL5qxDCn8IROtCxw==", + "dependencies": { + "System.Formats.Nrbf": "9.0.0" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "cUFTcMlz/Qw9s90b2wnWSCvHdjv51Bau9FQqhsr4TlwSe1OX+7SoXUqphis5G74MLOvMOCghxPPlEqOdCrVVGA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "o94k2RKuAce3GeDMlUvIXlhVa1kWpJw95E6C9LwW0KlG0nj5+SgCiIxJ2Eroqb9sLtG1mEMbFttZIBZ13EJPvQ==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "H2VFD4SFVxieywNxn9/epb63/IOcPPfA0WOtfkljzNfu7GCcHIBQNuwP6zGCEIi7Ci/oj8aLPUNK9sYImMFf4Q==", + "dependencies": { + "System.Windows.Extensions": "9.0.0" + } + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "U9msthvnH2Fsw7xwAvIhNHOdnIjOQTwOc8Vd0oGOsiRcGMGoBFlUD6qtYawRUoQdKH9ysxesZ9juFElt1Jw/7A==" + }, + "application": { + "type": "Project", + "dependencies": { + "Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.0, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.0, )" + } + }, + "domain": { + "type": "Project" + }, + "infrastructure": { + "type": "Project", + "dependencies": { + "Application": "[1.0.0, )", + "ClosedXML": "[0.105.0, )", + "Handlebars.Net": "[2.1.6, )", + "Microsoft.EntityFrameworkCore.SqlServer": "[10.0.0, )", + "PuppeteerSharp": "[20.2.5, )" + } + }, + "ClosedXML": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "U0hAdnYyPvF7TqHMFloxrS7pmozab79tFFF4c/bgPtqeelUs7ILpUd3r3c7C0a/DXsUZb3k1n4Pf7Q2LMyMQOg==", + "dependencies": { + "ClosedXML.Parser": "2.0.0", + "DocumentFormat.OpenXml": "[3.1.1, 4.0.0)", + "ExcelNumberFormat": "1.1.0", + "RBush.Signed": "4.0.0", + "SixLabors.Fonts": "1.0.0" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.0, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "Handlebars.Net": { + "type": "CentralTransitive", + "requested": "[2.1.6, )", + "resolved": "2.1.6", + "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "hHa2amRjMyBLUH/KTML6FgIAhZ0VFYkhCKwWEax0rO6iNeM1P5MflyeQLE5dniSIOZHc3Oqyv5UIyTFO4e1Auw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.0" + } + }, + "Microsoft.EntityFrameworkCore.SqlServer": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "9ip+r8Wtu0qNtFLYnQC3bkhQAsINIyo8XWYJHCVY22BFLxIT4rPxZPkbto56SjCnkwxS6nnbFZTU6KuAO2Nu1g==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.1", + "Microsoft.EntityFrameworkCore.Relational": "10.0.0" + } + }, + "PuppeteerSharp": { + "type": "CentralTransitive", + "requested": "[20.2.5, )", + "resolved": "20.2.5", + "contentHash": "w9/oEME3spddIKjh8C3sMxQziJe6lcdsTtQCnG3GevupZYQPx243dz1znh9YIE9otulXa9lg+Pnf/SMUCq4/Gw==" + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + } + } + } +} \ No newline at end of file diff --git a/frontend/Console/packages.lock.json b/frontend/Console/packages.lock.json new file mode 100644 index 0000000..9f42c1a --- /dev/null +++ b/frontend/Console/packages.lock.json @@ -0,0 +1,332 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.Extensions.Hosting": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "0jjfjQSOFZlHhwOoIQw0WyzxtkDMYdnPY3iFrOLasxYq/5J4FDt1HWT8TktBclOVjFY1FOOkoOc99X7AhbqSIw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.1", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", + "Microsoft.Extensions.Configuration.Json": "10.0.1", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.1", + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Configuration": "10.0.1", + "Microsoft.Extensions.Logging.Console": "10.0.1", + "Microsoft.Extensions.Logging.Debug": "10.0.1", + "Microsoft.Extensions.Logging.EventLog": "10.0.1", + "Microsoft.Extensions.Logging.EventSource": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Refit.HttpClientFactory": { + "type": "Direct", + "requested": "[9.0.2, )", + "resolved": "9.0.2", + "contentHash": "lv9vBbPaOZ6PARmWLkB3v+wBqoBDiI5ROS5KTbJEAAXMVaPG5V+AdjNmU+8NFkztZzJRqujREqmY69w7afRhJA==", + "dependencies": { + "Microsoft.Extensions.Http": "9.0.3", + "Refit": "9.0.2" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + }, + "Spectre.Console": { + "type": "Direct", + "requested": "[0.54.1-alpha.0.10, )", + "resolved": "0.54.1-alpha.0.10", + "contentHash": "qphEEXM7f82A6TH/ltagRFW3P0Uk48dEhBWm6KoW2iMn1uaGAC3cT661xWaqQqtiXw0xD1qVBagZXlmfinhkig==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "s5cxcdtIig66YT3J+7iHflMuorznK8kXuwBBPHMp4KImx5ZGE3FRa1Nj9fI/xMwFV+KzUMjqZ2MhOedPH8LiBQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "csD8Eps3HQ3yc0x6NhgTV+aIFKSs3qVlVCtFnMHz/JOjnv7eEj/qSXKXiK9LzBzB1qSfAVqFnB5iaX2nUmagIQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "N/6GiwiZFCBFZDk3vg1PhHW3zMqqu5WWpmeZAA9VTXv7Q8pr8NZR/EQsH0DjzqydDksJtY6EQBsu81d5okQOlA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "0zW3eYBJlRctmgqk5s0kFIi5o5y2g80mvGCD8bkYxREPQlKUnr0ndU/Sop+UDIhyWN0fIi4RW63vo7BKTi7ncA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "ULEJ0nkaW90JYJGkFujPcJtADXcJpXiSOLbokPcWJZ8iDbtDINifEYAUVqZVr81IDNTrRFul6O8RolOKOsgFPg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Json": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "+b3DligYSZuoWltU5YdbMpIEUHNZPgPrzWfNiIuDkMdqOl93UxYB5KzS3lgpRfTXJhTNpo/CZ8w/sTkDTPDdxQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4bxzGXIzZnz0Bf7czQ72jGvpOqJsRW/44PS7YLFXTTnu6cNcPvmSREDvBoH0ZVP2hAbMfL4sUoCUn54k70jPWw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "49dFvGJjLSwGn76eHnP1gBvCJkL8HRYpCrG0DCvsP6wRpEQRLN2Fq8rTxbP+6jS7jmYKCnSVO5C65v4mT3rzeA==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "qmoQkVZcbm4/gFpted3W3Y+1kTATZTcUhV3mRkbtpfBXlxWCHwh/2oMffVcCruaGOfJuEnyAsGyaSUouSdECOw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "9.0.3", + "contentHash": "rwChgI3lPqvUzsCN3egSW/6v4kP9/RQ2QrkZUwyAiHiwEoIB6QbYkATNvUsgjV6nfrekocyciCzy53ZFRuSaHA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", + "Microsoft.Extensions.Diagnostics": "9.0.3", + "Microsoft.Extensions.Logging": "9.0.3", + "Microsoft.Extensions.Logging.Abstractions": "9.0.3", + "Microsoft.Extensions.Options": "9.0.3" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Zg8LLnfZs5o2RCHD/+9NfDtJ40swauemwCa7sI8gQoAye/UJHRZNpCtC7a5XE7l9Z7mdI8iMWnLZ6m7Q6S3jLg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "38Q8sEHwQ/+wVO/mwQBa0fcdHbezFpusHE+vBw/dSr6Fq/kzZm3H/NQX511Jki/R3FHd64IY559gdlHZQtYeEA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Configuration": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "VqfTvbX9C6BA0VeIlpzPlljnNsXxiI5CdUHb9ksWERH94WQ6ft3oLGUAa4xKcDGu4xF+rIZ8wj7IOAd6/q7vGw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Zp9MM+jFCa7oktIug62V9eNygpkf+6kFVatF+UC/ODeUwIr5givYKy8fYSSI9sWdxqDqv63y1x0mm2VjOl8GOw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "System.Diagnostics.EventLog": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "WnFvZP+Y+lfeNFKPK/+mBpaCC7EeBDlobrQOqnP7rrw/+vE7yu8Rjczum1xbC0F/8cAHafog84DMp9200akMNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" + }, + "Refit": { + "type": "Transitive", + "resolved": "9.0.2", + "contentHash": "zLAfOh1iYE1bXFdnPjX5V6Nq8d4vBwxXaCovNNcAt9Fz3mfxAqHS3fckbBX4wetVgN1A6tIOzrDtPTN2c4FNrQ==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" + } + } + } +} \ No newline at end of file diff --git a/tests/ArchitectureTests/packages.lock.json b/tests/ArchitectureTests/packages.lock.json new file mode 100644 index 0000000..7e06a96 --- /dev/null +++ b/tests/ArchitectureTests/packages.lock.json @@ -0,0 +1,726 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.0.1, )", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "NetArchTest.Rules": { + "type": "Direct", + "requested": "[1.3.2, )", + "resolved": "1.3.2", + "contentHash": "puPyNXkwJq8/UwXhHV8NrzNzkQl4IxEbcP+3PU0xLRiOedsVpaSdpwHhvOZfI0VwTcRvawCNxYQcSRbD4RUg4w==", + "dependencies": { + "Mono.Cecil": "0.11.3" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "BMAJM2sGsTUw5FQ9upKQt6GFoldWksePgGpYjl56WSRvIuE3UxKZh0gAL+wDTIfLshUZm97VCVxlOGyrcjWz9Q==", + "dependencies": { + "Asp.Versioning.Http": "8.1.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.47.1", + "contentHash": "oPcncSsDHuxB8SC522z47xbp2+ttkcKv2YZ90KXhRKN0YQd2+7l1UURT9EBzUNEXtkLZUOAB5xbByMTrYRh3yA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.5.1", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.14.2", + "contentHash": "YhNMwOTwT+I2wIcJKSdP0ADyB2aK+JaYWZxO8LSRDm5w77LFr0ykR9xmt2ZV5T1gaI7xU6iNFIh/yW1dAlpddQ==", + "dependencies": { + "Azure.Core": "1.46.1", + "Microsoft.Identity.Client": "4.73.1", + "Microsoft.Identity.Client.Extensions.Msal": "4.73.1" + } + }, + "ClosedXML.Parser": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "ngTqjYreDYNytG1W5d3ewHsw0ukmmrgV7EKnS4/40rXoYZGt07jrBvo+N+GxT49rcageUMUiprV0jYT4nwVBHQ==" + }, + "DocumentFormat.OpenXml": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "2z9QBzeTLNNKWM9SaOSDMegfQk/7hDuElOsmF77pKZMkFRP/GHA/W/4yOAQD9kn15N/FsFxHn3QVYkatuZghiA==", + "dependencies": { + "DocumentFormat.OpenXml.Framework": "3.1.1" + } + }, + "DocumentFormat.OpenXml.Framework": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "6APEp/ElZV58S/4v8mf4Ke3ONEDORs64MqdD64Z7wWpcHANB9oovQsGIwtqjnKihulOj7T0a6IxHIHOfMqKOng==", + "dependencies": { + "System.IO.Packaging": "8.0.1" + } + }, + "ExcelNumberFormat": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "R3BVHPs9O+RkExbZYTGT0+9HLbi8ZrNij1Yziyw6znd3J7P3uoIR07uwTLGOogtz1p6+0sna66eBoXu7tBiVQA==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "YgZYAWzyNuPVtPq6WNm0bqOWNjYaWgl5mBWTGZyNoXitYBUYSp6iUB9AwK0V1mo793qRJUXz2t6UZrWITZSvuQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "syGQmIUPAYYHAHyTD8FCkTNThpQWvoA7crnIQRMfp8dyB5A2cWU3fQexlRTFkVmV7S0TjVmthi0LJEFVjHo8AQ==", + "dependencies": { + "Azure.Core": "1.47.1", + "Azure.Identity": "1.14.2", + "Microsoft.Bcl.Cryptography": "9.0.4", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.4", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "9.0.4", + "System.Security.Cryptography.Pkcs": "9.0.4" + } + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "C+TT9k7f1GQ8agOfV512K9iwrzi76RXVSDiLx+iWC9pz3QhEpSF1Dyk+FpVvd8ULQ+rqymfM8KQ7g48ttQVyMg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TxHQq0kn0tpYs2ljeRl8jtmWk720B0nteqI6mAZM77HWJpYT9Zj8SkkBBlj8K3Yeq18a6NBjz6YutE+shEk4Ag==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A3MX1ee7RDxWCUdx/KqP+74fbksz0UIhkVZh56YHvbPkEKsffCXgHU3LGkRDwqR/MrBNWLCWC/IVX79tzM30ZA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "RFYJR7APio/BiqdQunRq6DB+nDB6nc2qhHr77mlvZ0q0BT8PubMXN7XicmfzCbrDE/dzhBnUKBRXLTcqUiZDGg==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "NnDLS8QwYqO5ZZecL2oioi1LUqjh5Ewk4bMLzbgiXJbQmZhDLtKwLxL3DpGMlQAJ2G4KgEnvGPKa+OOgffeJbw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "xDztAiV2F0wI0W8FLKv5cbaBefyLD6JVaAsvgSN7bjWNCzGYzHbcOEIP5s4TJXUpQzMfUyBsFl1mC6Zmgpz0PQ==", + "dependencies": { + "Microsoft.Identity.Client": "4.73.1", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Mono.Cecil": { + "type": "Transitive", + "resolved": "0.11.3", + "contentHash": "DNYE+io5XfEE8+E+5padThTPHJARJHbz1mhbhMPNrrWGKVKKqj/KEeLvbawAmbIcT73NuxLV7itHZaYCZcVWGg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "RBush.Signed": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "aP5KQxL5RnFNGW1f0euYVBfCatkLw5iEzMRJcXKq8LWWP4Cp3+qoSq1tDDL2vvJ2rM0ychmVMa2VaEKLS6uX4w==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SixLabors.Fonts": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "LFQsCZlV0xlUyXAOMUo5kkSl+8zAQXXbbdwWchtk0B4o7zotZhQsQOcJUELGHdfPfm/xDAsz6hONAuV25bJaAg==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.5.1", + "contentHash": "k2jKSO0X45IqhVOT9iQB4xralNN9foRQsRvXBTyRpAVxyzCJlG895T9qYrQWbcJ6OQXxOouJQ37x5nZH5XKK+A==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "dvjqKp+2LpGid6phzrdrS/2mmEPxFl3jE1+L7614q4ZChKbLJCpHXg6sBILlCCED1t//EE+un/UdAetzIMpqnw==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.4", + "System.Security.Cryptography.ProtectedData": "9.0.4" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "KYkIOAvPexQOLDxPO2g0BVoWInnQhPpkFzRqvNrNrMhVT6kqhVr0zEb6KCHlptLFukxnZrjuMVAnxK7pOGUYrw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "cUFTcMlz/Qw9s90b2wnWSCvHdjv51Bau9FQqhsr4TlwSe1OX+7SoXUqphis5G74MLOvMOCghxPPlEqOdCrVVGA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "o94k2RKuAce3GeDMlUvIXlhVa1kWpJw95E6C9LwW0KlG0nj5+SgCiIxJ2Eroqb9sLtG1mEMbFttZIBZ13EJPvQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "application": { + "type": "Project", + "dependencies": { + "Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.0, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.0, )" + } + }, + "domain": { + "type": "Project" + }, + "infrastructure": { + "type": "Project", + "dependencies": { + "Application": "[1.0.0, )", + "ClosedXML": "[0.105.0, )", + "Handlebars.Net": "[2.1.6, )", + "Microsoft.EntityFrameworkCore.SqlServer": "[10.0.0, )", + "PuppeteerSharp": "[20.2.5, )" + } + }, + "webapi": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Http": "[8.1.0, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.0, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Infrastructure": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.0, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.File": "[7.0.1-dev-02315, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "Xu4xF62Cu9JqYi/CTa2TiK5kyHoa4EluPynj/bPFWDmlTIPzuJQbBI5RgFYVRFHjFVvWMoA77acRaFu7i7Wzqg==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "a90gW/4TF/14Bjiwg9LqNtdKGC4G3gu02+uynq3bCISfQm48km5chny4Yg5J4hixQPJUwwJJ9Do1G+jM8L9h3g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.0" + } + }, + "ClosedXML": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "U0hAdnYyPvF7TqHMFloxrS7pmozab79tFFF4c/bgPtqeelUs7ILpUd3r3c7C0a/DXsUZb3k1n4Pf7Q2LMyMQOg==", + "dependencies": { + "ClosedXML.Parser": "2.0.0", + "DocumentFormat.OpenXml": "[3.1.1, 4.0.0)", + "ExcelNumberFormat": "1.1.0", + "RBush.Signed": "4.0.0", + "SixLabors.Fonts": "1.0.0" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.0, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Handlebars.Net": { + "type": "CentralTransitive", + "requested": "[2.1.6, )", + "resolved": "2.1.6", + "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "0aqIF1t+sA2T62LIeMtXGSiaV7keGQaJnvwwmu+htQdjCaKYARfXAeqp4nHH9y2etpilyZ/tnQzZg4Ilmo/c4Q==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "hHa2amRjMyBLUH/KTML6FgIAhZ0VFYkhCKwWEax0rO6iNeM1P5MflyeQLE5dniSIOZHc3Oqyv5UIyTFO4e1Auw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.EntityFrameworkCore.SqlServer": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "9ip+r8Wtu0qNtFLYnQC3bkhQAsINIyo8XWYJHCVY22BFLxIT4rPxZPkbto56SjCnkwxS6nnbFZTU6KuAO2Nu1g==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.1", + "Microsoft.EntityFrameworkCore.Relational": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "PuppeteerSharp": { + "type": "CentralTransitive", + "requested": "[20.2.5, )", + "resolved": "20.2.5", + "contentHash": "w9/oEME3spddIKjh8C3sMxQziJe6lcdsTtQCnG3GevupZYQPx243dz1znh9YIE9otulXa9lg+Pnf/SMUCq4/Gw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.1-dev-02315, )", + "resolved": "7.0.1-dev-02315", + "contentHash": "LuwBs0Hrr7ptVY/u+h+GJ2Of0+Uv3JiISD3ipiuZYnsy+bqeHEC1LQPVTPW9MgjY3tg3B6HObVbL84qGBcbdBA==", + "dependencies": { + "Serilog": "4.2.0" + } + } + } + } +} \ No newline at end of file diff --git a/tests/IntegrationTests/packages.lock.json b/tests/IntegrationTests/packages.lock.json new file mode 100644 index 0000000..5927e1b --- /dev/null +++ b/tests/IntegrationTests/packages.lock.json @@ -0,0 +1,921 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Bogus": { + "type": "Direct", + "requested": "[35.6.5, )", + "resolved": "35.6.5", + "contentHash": "2FGZn+aAVHjmCgClgmGkTDBVZk0zkLvAKGaxEf5JL6b3i9JbHTE4wnuY4vHCuzlCmJdU6VZjgDfHwmYkQF8VAA==" + }, + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "Gdtv34h2qvynOEu+B2+6apBiiPhEs39namGax02UgaQMRetlxQ88p2/jK1eIdz3m1NRgYszNBN/jBdXkucZhvw==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Microsoft.Extensions.Hosting": "10.0.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.0.1, )", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "BMAJM2sGsTUw5FQ9upKQt6GFoldWksePgGpYjl56WSRvIuE3UxKZh0gAL+wDTIfLshUZm97VCVxlOGyrcjWz9Q==", + "dependencies": { + "Asp.Versioning.Http": "8.1.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.47.1", + "contentHash": "oPcncSsDHuxB8SC522z47xbp2+ttkcKv2YZ90KXhRKN0YQd2+7l1UURT9EBzUNEXtkLZUOAB5xbByMTrYRh3yA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.5.1", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.14.2", + "contentHash": "YhNMwOTwT+I2wIcJKSdP0ADyB2aK+JaYWZxO8LSRDm5w77LFr0ykR9xmt2ZV5T1gaI7xU6iNFIh/yW1dAlpddQ==", + "dependencies": { + "Azure.Core": "1.46.1", + "Microsoft.Identity.Client": "4.73.1", + "Microsoft.Identity.Client.Extensions.Msal": "4.73.1" + } + }, + "ClosedXML.Parser": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "ngTqjYreDYNytG1W5d3ewHsw0ukmmrgV7EKnS4/40rXoYZGt07jrBvo+N+GxT49rcageUMUiprV0jYT4nwVBHQ==" + }, + "DocumentFormat.OpenXml": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "2z9QBzeTLNNKWM9SaOSDMegfQk/7hDuElOsmF77pKZMkFRP/GHA/W/4yOAQD9kn15N/FsFxHn3QVYkatuZghiA==", + "dependencies": { + "DocumentFormat.OpenXml.Framework": "3.1.1" + } + }, + "DocumentFormat.OpenXml.Framework": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "6APEp/ElZV58S/4v8mf4Ke3ONEDORs64MqdD64Z7wWpcHANB9oovQsGIwtqjnKihulOj7T0a6IxHIHOfMqKOng==", + "dependencies": { + "System.IO.Packaging": "8.0.1" + } + }, + "ExcelNumberFormat": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "R3BVHPs9O+RkExbZYTGT0+9HLbi8ZrNij1Yziyw6znd3J7P3uoIR07uwTLGOogtz1p6+0sna66eBoXu7tBiVQA==" + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Q3ia+k+wYM3Iv/Qq5IETOdpz/R0xizs3WNAXz699vEQx5TMVAfG715fBSq9Thzopvx8dYZkxQ/mumTn6AJ/vGQ==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "YgZYAWzyNuPVtPq6WNm0bqOWNjYaWgl5mBWTGZyNoXitYBUYSp6iUB9AwK0V1mo793qRJUXz2t6UZrWITZSvuQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "syGQmIUPAYYHAHyTD8FCkTNThpQWvoA7crnIQRMfp8dyB5A2cWU3fQexlRTFkVmV7S0TjVmthi0LJEFVjHo8AQ==", + "dependencies": { + "Azure.Core": "1.47.1", + "Azure.Identity": "1.14.2", + "Microsoft.Bcl.Cryptography": "9.0.4", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.4", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "9.0.4", + "System.Security.Cryptography.Pkcs": "9.0.4" + } + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "C+TT9k7f1GQ8agOfV512K9iwrzi76RXVSDiLx+iWC9pz3QhEpSF1Dyk+FpVvd8ULQ+rqymfM8KQ7g48ttQVyMg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TxHQq0kn0tpYs2ljeRl8jtmWk720B0nteqI6mAZM77HWJpYT9Zj8SkkBBlj8K3Yeq18a6NBjz6YutE+shEk4Ag==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A3MX1ee7RDxWCUdx/KqP+74fbksz0UIhkVZh56YHvbPkEKsffCXgHU3LGkRDwqR/MrBNWLCWC/IVX79tzM30ZA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "CRj5clwZciVs46GMhAthkFq3+JiNM15Bz9CRlCZLBmRdggD6RwoBphRJ+EUDK2f+cZZ1L2zqVaQrn1KueoU5Kg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TmFegsI/uCdwMBD4yKpmO+OkjVNHQL49Dh/ep83NI5rPUEoBK9OdsJo1zURc1A2FuS/R/Pos3wsTjlyLnguBLA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LqCTyF0twrG4tyEN6PpSC5ewRBDwCBazRUfCOdRddwaQ3n2S57GDDeYOlTLcbV/V2dxSSZWg5Ofr48h6BsBmxw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BIOPTEAZoeWbHlDT9Zudu+rpecZizFwhdIFRiyZKDml7JbayXmfTXKUt+ezifsSXfBkWDdJM10oDOxo8pufEng==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "B4qHB6gQ2B3I52YRohSV7wetp01BQzi8jDmrtiVm6e4l8vH5vjqwxWcR5wumGWjdBkj1asJLLsDIocdyTQSP0A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Json": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "RFYJR7APio/BiqdQunRq6DB+nDB6nc2qhHr77mlvZ0q0BT8PubMXN7XicmfzCbrDE/dzhBnUKBRXLTcqUiZDGg==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xjkxIPgrT0mKTfBwb+CVqZnRchyZgzKIfDQOp8z+WUC6vPe3WokIf71z+hJPkH0YBUYJwa7Z/al1R087ib9oiw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "UZUQ74lQMmvcprlG8w+XpxBbyRDQqfb7GAnccITw32hdkUBlmm9yNC4xl4aR9YjgV3ounZcub194sdmLSfBmPA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "5hfVl/e+bx1px2UkN+1xXhd3hu7Ui6ENItBzckFaRDQXfr+SHT/7qrCDrlQekCF/PBtEu2vtk87U2+gDEF8EhQ==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "treWetuksp8LVb09fCJ5zNhNJjyDkqzVm83XxcrlWQnAdXznR140UUXo8PyEPBvFlHhjKhFQZEOP3Sk/ByCvEw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A/4vBtVaySLBGj4qluye+KSbeVCCMa6GcTbxf2YgnSDHs9b9105+VojBJ1eJPel8F1ny0JOh+Ci3vgCKn69tNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "EWda5nSXhzQZr3yJ3+XgIApOek+Hm+txhWCEzWNVPp/OfimL4qmvctgXu87m+S2RXw/AoUP8aLMNicJ2KWblVA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "System.Diagnostics.EventLog": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "+Qc+kgoJi1w2A/Jm+7h04LcK2JoJkwAxKg7kBakkNRcemTmRGocqPa7rVNVGorTYruFrUS25GwkFNtOECnjhXg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "NnDLS8QwYqO5ZZecL2oioi1LUqjh5Ewk4bMLzbgiXJbQmZhDLtKwLxL3DpGMlQAJ2G4KgEnvGPKa+OOgffeJbw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "xDztAiV2F0wI0W8FLKv5cbaBefyLD6JVaAsvgSN7bjWNCzGYzHbcOEIP5s4TJXUpQzMfUyBsFl1mC6Zmgpz0PQ==", + "dependencies": { + "Microsoft.Identity.Client": "4.73.1", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "RBush.Signed": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "aP5KQxL5RnFNGW1f0euYVBfCatkLw5iEzMRJcXKq8LWWP4Cp3+qoSq1tDDL2vvJ2rM0ychmVMa2VaEKLS6uX4w==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SixLabors.Fonts": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "LFQsCZlV0xlUyXAOMUo5kkSl+8zAQXXbbdwWchtk0B4o7zotZhQsQOcJUELGHdfPfm/xDAsz6hONAuV25bJaAg==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.5.1", + "contentHash": "k2jKSO0X45IqhVOT9iQB4xralNN9foRQsRvXBTyRpAVxyzCJlG895T9qYrQWbcJ6OQXxOouJQ37x5nZH5XKK+A==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "dvjqKp+2LpGid6phzrdrS/2mmEPxFl3jE1+L7614q4ZChKbLJCpHXg6sBILlCCED1t//EE+un/UdAetzIMpqnw==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.4", + "System.Security.Cryptography.ProtectedData": "9.0.4" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "uaFRda9NjtbJRkdx311eXlAA3n2em7223c1A8d1VWyl+4FL9vkG7y2lpPfBU9HYdj/9KgdRNdn1vFK8ZYCYT/A==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "KYkIOAvPexQOLDxPO2g0BVoWInnQhPpkFzRqvNrNrMhVT6kqhVr0zEb6KCHlptLFukxnZrjuMVAnxK7pOGUYrw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "cUFTcMlz/Qw9s90b2wnWSCvHdjv51Bau9FQqhsr4TlwSe1OX+7SoXUqphis5G74MLOvMOCghxPPlEqOdCrVVGA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "o94k2RKuAce3GeDMlUvIXlhVa1kWpJw95E6C9LwW0KlG0nj5+SgCiIxJ2Eroqb9sLtG1mEMbFttZIBZ13EJPvQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "application": { + "type": "Project", + "dependencies": { + "Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.0, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.0, )" + } + }, + "domain": { + "type": "Project" + }, + "infrastructure": { + "type": "Project", + "dependencies": { + "Application": "[1.0.0, )", + "ClosedXML": "[0.105.0, )", + "Handlebars.Net": "[2.1.6, )", + "Microsoft.EntityFrameworkCore.SqlServer": "[10.0.0, )", + "PuppeteerSharp": "[20.2.5, )" + } + }, + "testdata": { + "type": "Project", + "dependencies": { + "Bogus": "[35.6.5, )", + "WebApi": "[1.0.0, )" + } + }, + "webapi": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Http": "[8.1.0, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.0, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Infrastructure": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.0, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.File": "[7.0.1-dev-02315, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "Xu4xF62Cu9JqYi/CTa2TiK5kyHoa4EluPynj/bPFWDmlTIPzuJQbBI5RgFYVRFHjFVvWMoA77acRaFu7i7Wzqg==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "a90gW/4TF/14Bjiwg9LqNtdKGC4G3gu02+uynq3bCISfQm48km5chny4Yg5J4hixQPJUwwJJ9Do1G+jM8L9h3g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.0" + } + }, + "ClosedXML": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "U0hAdnYyPvF7TqHMFloxrS7pmozab79tFFF4c/bgPtqeelUs7ILpUd3r3c7C0a/DXsUZb3k1n4Pf7Q2LMyMQOg==", + "dependencies": { + "ClosedXML.Parser": "2.0.0", + "DocumentFormat.OpenXml": "[3.1.1, 4.0.0)", + "ExcelNumberFormat": "1.1.0", + "RBush.Signed": "4.0.0", + "SixLabors.Fonts": "1.0.0" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.0, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Handlebars.Net": { + "type": "CentralTransitive", + "requested": "[2.1.6, )", + "resolved": "2.1.6", + "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "0aqIF1t+sA2T62LIeMtXGSiaV7keGQaJnvwwmu+htQdjCaKYARfXAeqp4nHH9y2etpilyZ/tnQzZg4Ilmo/c4Q==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "hHa2amRjMyBLUH/KTML6FgIAhZ0VFYkhCKwWEax0rO6iNeM1P5MflyeQLE5dniSIOZHc3Oqyv5UIyTFO4e1Auw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.EntityFrameworkCore.SqlServer": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "9ip+r8Wtu0qNtFLYnQC3bkhQAsINIyo8XWYJHCVY22BFLxIT4rPxZPkbto56SjCnkwxS6nnbFZTU6KuAO2Nu1g==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.1", + "Microsoft.EntityFrameworkCore.Relational": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.0", + "contentHash": "yKJiVdXkSfe9foojGpBRbuDPQI8YD71IO/aE8ehGjRHE0VkEF/YWkW6StthwuFF146pc2lypZrpk/Tks6Plwhw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.0", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", + "Microsoft.Extensions.Configuration.Json": "10.0.0", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.0", + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "Microsoft.Extensions.Logging.Console": "10.0.0", + "Microsoft.Extensions.Logging.Debug": "10.0.0", + "Microsoft.Extensions.Logging.EventLog": "10.0.0", + "Microsoft.Extensions.Logging.EventSource": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "PuppeteerSharp": { + "type": "CentralTransitive", + "requested": "[20.2.5, )", + "resolved": "20.2.5", + "contentHash": "w9/oEME3spddIKjh8C3sMxQziJe6lcdsTtQCnG3GevupZYQPx243dz1znh9YIE9otulXa9lg+Pnf/SMUCq4/Gw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.1-dev-02315, )", + "resolved": "7.0.1-dev-02315", + "contentHash": "LuwBs0Hrr7ptVY/u+h+GJ2Of0+Uv3JiISD3ipiuZYnsy+bqeHEC1LQPVTPW9MgjY3tg3B6HObVbL84qGBcbdBA==", + "dependencies": { + "Serilog": "4.2.0" + } + } + } + } +} \ No newline at end of file diff --git a/tests/TestData/packages.lock.json b/tests/TestData/packages.lock.json new file mode 100644 index 0000000..f08b81f --- /dev/null +++ b/tests/TestData/packages.lock.json @@ -0,0 +1,621 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Bogus": { + "type": "Direct", + "requested": "[35.6.5, )", + "resolved": "35.6.5", + "contentHash": "2FGZn+aAVHjmCgClgmGkTDBVZk0zkLvAKGaxEf5JL6b3i9JbHTE4wnuY4vHCuzlCmJdU6VZjgDfHwmYkQF8VAA==" + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "BMAJM2sGsTUw5FQ9upKQt6GFoldWksePgGpYjl56WSRvIuE3UxKZh0gAL+wDTIfLshUZm97VCVxlOGyrcjWz9Q==", + "dependencies": { + "Asp.Versioning.Http": "8.1.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.47.1", + "contentHash": "oPcncSsDHuxB8SC522z47xbp2+ttkcKv2YZ90KXhRKN0YQd2+7l1UURT9EBzUNEXtkLZUOAB5xbByMTrYRh3yA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.5.1", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.14.2", + "contentHash": "YhNMwOTwT+I2wIcJKSdP0ADyB2aK+JaYWZxO8LSRDm5w77LFr0ykR9xmt2ZV5T1gaI7xU6iNFIh/yW1dAlpddQ==", + "dependencies": { + "Azure.Core": "1.46.1", + "Microsoft.Identity.Client": "4.73.1", + "Microsoft.Identity.Client.Extensions.Msal": "4.73.1" + } + }, + "ClosedXML.Parser": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "ngTqjYreDYNytG1W5d3ewHsw0ukmmrgV7EKnS4/40rXoYZGt07jrBvo+N+GxT49rcageUMUiprV0jYT4nwVBHQ==" + }, + "DocumentFormat.OpenXml": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "2z9QBzeTLNNKWM9SaOSDMegfQk/7hDuElOsmF77pKZMkFRP/GHA/W/4yOAQD9kn15N/FsFxHn3QVYkatuZghiA==", + "dependencies": { + "DocumentFormat.OpenXml.Framework": "3.1.1" + } + }, + "DocumentFormat.OpenXml.Framework": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "6APEp/ElZV58S/4v8mf4Ke3ONEDORs64MqdD64Z7wWpcHANB9oovQsGIwtqjnKihulOj7T0a6IxHIHOfMqKOng==", + "dependencies": { + "System.IO.Packaging": "8.0.1" + } + }, + "ExcelNumberFormat": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "R3BVHPs9O+RkExbZYTGT0+9HLbi8ZrNij1Yziyw6znd3J7P3uoIR07uwTLGOogtz1p6+0sna66eBoXu7tBiVQA==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "YgZYAWzyNuPVtPq6WNm0bqOWNjYaWgl5mBWTGZyNoXitYBUYSp6iUB9AwK0V1mo793qRJUXz2t6UZrWITZSvuQ==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "syGQmIUPAYYHAHyTD8FCkTNThpQWvoA7crnIQRMfp8dyB5A2cWU3fQexlRTFkVmV7S0TjVmthi0LJEFVjHo8AQ==", + "dependencies": { + "Azure.Core": "1.47.1", + "Azure.Identity": "1.14.2", + "Microsoft.Bcl.Cryptography": "9.0.4", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.4", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "9.0.4", + "System.Security.Cryptography.Pkcs": "9.0.4" + } + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "C+TT9k7f1GQ8agOfV512K9iwrzi76RXVSDiLx+iWC9pz3QhEpSF1Dyk+FpVvd8ULQ+rqymfM8KQ7g48ttQVyMg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TxHQq0kn0tpYs2ljeRl8jtmWk720B0nteqI6mAZM77HWJpYT9Zj8SkkBBlj8K3Yeq18a6NBjz6YutE+shEk4Ag==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A3MX1ee7RDxWCUdx/KqP+74fbksz0UIhkVZh56YHvbPkEKsffCXgHU3LGkRDwqR/MrBNWLCWC/IVX79tzM30ZA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "RFYJR7APio/BiqdQunRq6DB+nDB6nc2qhHr77mlvZ0q0BT8PubMXN7XicmfzCbrDE/dzhBnUKBRXLTcqUiZDGg==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "NnDLS8QwYqO5ZZecL2oioi1LUqjh5Ewk4bMLzbgiXJbQmZhDLtKwLxL3DpGMlQAJ2G4KgEnvGPKa+OOgffeJbw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "xDztAiV2F0wI0W8FLKv5cbaBefyLD6JVaAsvgSN7bjWNCzGYzHbcOEIP5s4TJXUpQzMfUyBsFl1mC6Zmgpz0PQ==", + "dependencies": { + "Microsoft.Identity.Client": "4.73.1", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "RBush.Signed": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "aP5KQxL5RnFNGW1f0euYVBfCatkLw5iEzMRJcXKq8LWWP4Cp3+qoSq1tDDL2vvJ2rM0ychmVMa2VaEKLS6uX4w==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SixLabors.Fonts": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "LFQsCZlV0xlUyXAOMUo5kkSl+8zAQXXbbdwWchtk0B4o7zotZhQsQOcJUELGHdfPfm/xDAsz6hONAuV25bJaAg==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.5.1", + "contentHash": "k2jKSO0X45IqhVOT9iQB4xralNN9foRQsRvXBTyRpAVxyzCJlG895T9qYrQWbcJ6OQXxOouJQ37x5nZH5XKK+A==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "dvjqKp+2LpGid6phzrdrS/2mmEPxFl3jE1+L7614q4ZChKbLJCpHXg6sBILlCCED1t//EE+un/UdAetzIMpqnw==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.4", + "System.Security.Cryptography.ProtectedData": "9.0.4" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "KYkIOAvPexQOLDxPO2g0BVoWInnQhPpkFzRqvNrNrMhVT6kqhVr0zEb6KCHlptLFukxnZrjuMVAnxK7pOGUYrw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "cUFTcMlz/Qw9s90b2wnWSCvHdjv51Bau9FQqhsr4TlwSe1OX+7SoXUqphis5G74MLOvMOCghxPPlEqOdCrVVGA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "o94k2RKuAce3GeDMlUvIXlhVa1kWpJw95E6C9LwW0KlG0nj5+SgCiIxJ2Eroqb9sLtG1mEMbFttZIBZ13EJPvQ==" + }, + "application": { + "type": "Project", + "dependencies": { + "Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.0, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.0, )" + } + }, + "domain": { + "type": "Project" + }, + "infrastructure": { + "type": "Project", + "dependencies": { + "Application": "[1.0.0, )", + "ClosedXML": "[0.105.0, )", + "Handlebars.Net": "[2.1.6, )", + "Microsoft.EntityFrameworkCore.SqlServer": "[10.0.0, )", + "PuppeteerSharp": "[20.2.5, )" + } + }, + "webapi": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Http": "[8.1.0, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.0, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Infrastructure": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.0, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.File": "[7.0.1-dev-02315, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "Xu4xF62Cu9JqYi/CTa2TiK5kyHoa4EluPynj/bPFWDmlTIPzuJQbBI5RgFYVRFHjFVvWMoA77acRaFu7i7Wzqg==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "a90gW/4TF/14Bjiwg9LqNtdKGC4G3gu02+uynq3bCISfQm48km5chny4Yg5J4hixQPJUwwJJ9Do1G+jM8L9h3g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.0" + } + }, + "ClosedXML": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "U0hAdnYyPvF7TqHMFloxrS7pmozab79tFFF4c/bgPtqeelUs7ILpUd3r3c7C0a/DXsUZb3k1n4Pf7Q2LMyMQOg==", + "dependencies": { + "ClosedXML.Parser": "2.0.0", + "DocumentFormat.OpenXml": "[3.1.1, 4.0.0)", + "ExcelNumberFormat": "1.1.0", + "RBush.Signed": "4.0.0", + "SixLabors.Fonts": "1.0.0" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.0, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Handlebars.Net": { + "type": "CentralTransitive", + "requested": "[2.1.6, )", + "resolved": "2.1.6", + "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "0aqIF1t+sA2T62LIeMtXGSiaV7keGQaJnvwwmu+htQdjCaKYARfXAeqp4nHH9y2etpilyZ/tnQzZg4Ilmo/c4Q==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "hHa2amRjMyBLUH/KTML6FgIAhZ0VFYkhCKwWEax0rO6iNeM1P5MflyeQLE5dniSIOZHc3Oqyv5UIyTFO4e1Auw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.EntityFrameworkCore.SqlServer": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "9ip+r8Wtu0qNtFLYnQC3bkhQAsINIyo8XWYJHCVY22BFLxIT4rPxZPkbto56SjCnkwxS6nnbFZTU6KuAO2Nu1g==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.1", + "Microsoft.EntityFrameworkCore.Relational": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "PuppeteerSharp": { + "type": "CentralTransitive", + "requested": "[20.2.5, )", + "resolved": "20.2.5", + "contentHash": "w9/oEME3spddIKjh8C3sMxQziJe6lcdsTtQCnG3GevupZYQPx243dz1znh9YIE9otulXa9lg+Pnf/SMUCq4/Gw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.1-dev-02315, )", + "resolved": "7.0.1-dev-02315", + "contentHash": "LuwBs0Hrr7ptVY/u+h+GJ2Of0+Uv3JiISD3ipiuZYnsy+bqeHEC1LQPVTPW9MgjY3tg3B6HObVbL84qGBcbdBA==", + "dependencies": { + "Serilog": "4.2.0" + } + } + } + } +} \ No newline at end of file diff --git a/tests/UnitTests/packages.lock.json b/tests/UnitTests/packages.lock.json new file mode 100644 index 0000000..25e4c12 --- /dev/null +++ b/tests/UnitTests/packages.lock.json @@ -0,0 +1,725 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.0.1, )", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.16.1.129956, )", + "resolved": "10.16.1.129956", + "contentHash": "XjMarxz00Xc+GD8JGYut1swL0RIw2iwfBhltEITsxsqC/MDLjK+8dk58fPYkS+YPwtdqzkU4VUuSqX3qrN2HYA==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "BMAJM2sGsTUw5FQ9upKQt6GFoldWksePgGpYjl56WSRvIuE3UxKZh0gAL+wDTIfLshUZm97VCVxlOGyrcjWz9Q==", + "dependencies": { + "Asp.Versioning.Http": "8.1.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.47.1", + "contentHash": "oPcncSsDHuxB8SC522z47xbp2+ttkcKv2YZ90KXhRKN0YQd2+7l1UURT9EBzUNEXtkLZUOAB5xbByMTrYRh3yA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.5.1", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.14.2", + "contentHash": "YhNMwOTwT+I2wIcJKSdP0ADyB2aK+JaYWZxO8LSRDm5w77LFr0ykR9xmt2ZV5T1gaI7xU6iNFIh/yW1dAlpddQ==", + "dependencies": { + "Azure.Core": "1.46.1", + "Microsoft.Identity.Client": "4.73.1", + "Microsoft.Identity.Client.Extensions.Msal": "4.73.1" + } + }, + "ClosedXML.Parser": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "ngTqjYreDYNytG1W5d3ewHsw0ukmmrgV7EKnS4/40rXoYZGt07jrBvo+N+GxT49rcageUMUiprV0jYT4nwVBHQ==" + }, + "DocumentFormat.OpenXml": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "2z9QBzeTLNNKWM9SaOSDMegfQk/7hDuElOsmF77pKZMkFRP/GHA/W/4yOAQD9kn15N/FsFxHn3QVYkatuZghiA==", + "dependencies": { + "DocumentFormat.OpenXml.Framework": "3.1.1" + } + }, + "DocumentFormat.OpenXml.Framework": { + "type": "Transitive", + "resolved": "3.1.1", + "contentHash": "6APEp/ElZV58S/4v8mf4Ke3ONEDORs64MqdD64Z7wWpcHANB9oovQsGIwtqjnKihulOj7T0a6IxHIHOfMqKOng==", + "dependencies": { + "System.IO.Packaging": "8.0.1" + } + }, + "ExcelNumberFormat": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "R3BVHPs9O+RkExbZYTGT0+9HLbi8ZrNij1Yziyw6znd3J7P3uoIR07uwTLGOogtz1p6+0sna66eBoXu7tBiVQA==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "YgZYAWzyNuPVtPq6WNm0bqOWNjYaWgl5mBWTGZyNoXitYBUYSp6iUB9AwK0V1mo793qRJUXz2t6UZrWITZSvuQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "syGQmIUPAYYHAHyTD8FCkTNThpQWvoA7crnIQRMfp8dyB5A2cWU3fQexlRTFkVmV7S0TjVmthi0LJEFVjHo8AQ==", + "dependencies": { + "Azure.Core": "1.47.1", + "Azure.Identity": "1.14.2", + "Microsoft.Bcl.Cryptography": "9.0.4", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.4", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "9.0.4", + "System.Security.Cryptography.Pkcs": "9.0.4" + } + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "C+TT9k7f1GQ8agOfV512K9iwrzi76RXVSDiLx+iWC9pz3QhEpSF1Dyk+FpVvd8ULQ+rqymfM8KQ7g48ttQVyMg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TxHQq0kn0tpYs2ljeRl8jtmWk720B0nteqI6mAZM77HWJpYT9Zj8SkkBBlj8K3Yeq18a6NBjz6YutE+shEk4Ag==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A3MX1ee7RDxWCUdx/KqP+74fbksz0UIhkVZh56YHvbPkEKsffCXgHU3LGkRDwqR/MrBNWLCWC/IVX79tzM30ZA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "RFYJR7APio/BiqdQunRq6DB+nDB6nc2qhHr77mlvZ0q0BT8PubMXN7XicmfzCbrDE/dzhBnUKBRXLTcqUiZDGg==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "NnDLS8QwYqO5ZZecL2oioi1LUqjh5Ewk4bMLzbgiXJbQmZhDLtKwLxL3DpGMlQAJ2G4KgEnvGPKa+OOgffeJbw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.73.1", + "contentHash": "xDztAiV2F0wI0W8FLKv5cbaBefyLD6JVaAsvgSN7bjWNCzGYzHbcOEIP5s4TJXUpQzMfUyBsFl1mC6Zmgpz0PQ==", + "dependencies": { + "Microsoft.Identity.Client": "4.73.1", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "RBush.Signed": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "aP5KQxL5RnFNGW1f0euYVBfCatkLw5iEzMRJcXKq8LWWP4Cp3+qoSq1tDDL2vvJ2rM0ychmVMa2VaEKLS6uX4w==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SixLabors.Fonts": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "LFQsCZlV0xlUyXAOMUo5kkSl+8zAQXXbbdwWchtk0B4o7zotZhQsQOcJUELGHdfPfm/xDAsz6hONAuV25bJaAg==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.5.1", + "contentHash": "k2jKSO0X45IqhVOT9iQB4xralNN9foRQsRvXBTyRpAVxyzCJlG895T9qYrQWbcJ6OQXxOouJQ37x5nZH5XKK+A==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "dvjqKp+2LpGid6phzrdrS/2mmEPxFl3jE1+L7614q4ZChKbLJCpHXg6sBILlCCED1t//EE+un/UdAetzIMpqnw==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.4", + "System.Security.Cryptography.ProtectedData": "9.0.4" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "KYkIOAvPexQOLDxPO2g0BVoWInnQhPpkFzRqvNrNrMhVT6kqhVr0zEb6KCHlptLFukxnZrjuMVAnxK7pOGUYrw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "cUFTcMlz/Qw9s90b2wnWSCvHdjv51Bau9FQqhsr4TlwSe1OX+7SoXUqphis5G74MLOvMOCghxPPlEqOdCrVVGA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "o94k2RKuAce3GeDMlUvIXlhVa1kWpJw95E6C9LwW0KlG0nj5+SgCiIxJ2Eroqb9sLtG1mEMbFttZIBZ13EJPvQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "application": { + "type": "Project", + "dependencies": { + "Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.0, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.0, )" + } + }, + "domain": { + "type": "Project" + }, + "infrastructure": { + "type": "Project", + "dependencies": { + "Application": "[1.0.0, )", + "ClosedXML": "[0.105.0, )", + "Handlebars.Net": "[2.1.6, )", + "Microsoft.EntityFrameworkCore.SqlServer": "[10.0.0, )", + "PuppeteerSharp": "[20.2.5, )" + } + }, + "testdata": { + "type": "Project", + "dependencies": { + "Bogus": "[35.6.5, )", + "WebApi": "[1.0.0, )" + } + }, + "webapi": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Http": "[8.1.0, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.0, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Infrastructure": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.0, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.File": "[7.0.1-dev-02315, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "Xu4xF62Cu9JqYi/CTa2TiK5kyHoa4EluPynj/bPFWDmlTIPzuJQbBI5RgFYVRFHjFVvWMoA77acRaFu7i7Wzqg==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.0, )", + "resolved": "8.1.0", + "contentHash": "a90gW/4TF/14Bjiwg9LqNtdKGC4G3gu02+uynq3bCISfQm48km5chny4Yg5J4hixQPJUwwJJ9Do1G+jM8L9h3g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.0" + } + }, + "Bogus": { + "type": "CentralTransitive", + "requested": "[35.6.5, )", + "resolved": "35.6.5", + "contentHash": "2FGZn+aAVHjmCgClgmGkTDBVZk0zkLvAKGaxEf5JL6b3i9JbHTE4wnuY4vHCuzlCmJdU6VZjgDfHwmYkQF8VAA==" + }, + "ClosedXML": { + "type": "CentralTransitive", + "requested": "[0.105.0, )", + "resolved": "0.105.0", + "contentHash": "U0hAdnYyPvF7TqHMFloxrS7pmozab79tFFF4c/bgPtqeelUs7ILpUd3r3c7C0a/DXsUZb3k1n4Pf7Q2LMyMQOg==", + "dependencies": { + "ClosedXML.Parser": "2.0.0", + "DocumentFormat.OpenXml": "[3.1.1, 4.0.0)", + "ExcelNumberFormat": "1.1.0", + "RBush.Signed": "4.0.0", + "SixLabors.Fonts": "1.0.0" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.0, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Handlebars.Net": { + "type": "CentralTransitive", + "requested": "[2.1.6, )", + "resolved": "2.1.6", + "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "0aqIF1t+sA2T62LIeMtXGSiaV7keGQaJnvwwmu+htQdjCaKYARfXAeqp4nHH9y2etpilyZ/tnQzZg4Ilmo/c4Q==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "hHa2amRjMyBLUH/KTML6FgIAhZ0VFYkhCKwWEax0rO6iNeM1P5MflyeQLE5dniSIOZHc3Oqyv5UIyTFO4e1Auw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.0", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "Microsoft.EntityFrameworkCore.SqlServer": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "9ip+r8Wtu0qNtFLYnQC3bkhQAsINIyo8XWYJHCVY22BFLxIT4rPxZPkbto56SjCnkwxS6nnbFZTU6KuAO2Nu1g==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.1", + "Microsoft.EntityFrameworkCore.Relational": "10.0.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0" + } + }, + "PuppeteerSharp": { + "type": "CentralTransitive", + "requested": "[20.2.5, )", + "resolved": "20.2.5", + "contentHash": "w9/oEME3spddIKjh8C3sMxQziJe6lcdsTtQCnG3GevupZYQPx243dz1znh9YIE9otulXa9lg+Pnf/SMUCq4/Gw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.1-dev-02315, )", + "resolved": "7.0.1-dev-02315", + "contentHash": "LuwBs0Hrr7ptVY/u+h+GJ2Of0+Uv3JiISD3ipiuZYnsy+bqeHEC1LQPVTPW9MgjY3tg3B6HObVbL84qGBcbdBA==", + "dependencies": { + "Serilog": "4.2.0" + } + } + } + } +} \ No newline at end of file From 92c0ae65965166231cc94cd0676fe46710c0d840 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:12:06 +0100 Subject: [PATCH 45/46] docs: add README documentation (#82) * add README documentation * add Table of contents * add Console preview image * remove ignored paths from required workflows --- .github/workflows/build-validation.yml | 4 - .github/workflows/test-validation.yml | 4 - README.md | 114 +++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-validation.yml b/.github/workflows/build-validation.yml index 74236f5..04af5f4 100644 --- a/.github/workflows/build-validation.yml +++ b/.github/workflows/build-validation.yml @@ -5,10 +5,6 @@ on: branches: - main - develop - paths-ignore: - - "**/*.md" - - "**/*.gitignore" - - "**/*.gitattributes" workflow_dispatch: env: diff --git a/.github/workflows/test-validation.yml b/.github/workflows/test-validation.yml index b3d58c2..037321a 100644 --- a/.github/workflows/test-validation.yml +++ b/.github/workflows/test-validation.yml @@ -5,10 +5,6 @@ on: branches: - main - develop - paths-ignore: - - "**/*.md" - - "**/*.gitignore" - - "**/*.gitattributes" workflow_dispatch: env: diff --git a/README.md b/README.md index bb7cde8..a96e0ce 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,117 @@ [![build](https://github.com/nwdorian/PhoneForge/actions/workflows/build-validation.yml/badge.svg)](https://github.com/nwdorian/PhoneForge/actions/workflows/build-validation.yml) [![tests](https://github.com/nwdorian/PhoneForge/actions/workflows/test-validation.yml/badge.svg)](https://github.com/nwdorian/PhoneForge/actions/workflows/test-validation.yml) + +This application is designed to demonstrate document processing in ASP.NET + +It allows users to work with contact data like in a phone book. They can load data from an Excel file and create PDF reports based on selected filters. + +Backend is a .NET Web API and frontend is a simple console application for contact management and PDF report generation. + +## Table of contents + +- [PhoneForge](#phoneforge) + - [Table of contents](#table-of-contents) + - [Technologies](#technologies) + - [Getting started](#getting-started) + - [Prerequisites](#prerequisites) + - [Installation](#installation) + - [Features](#features) + - [Solution structure](#solution-structure) + - [Best practices](#best-practices) + - [Contributing](#contributing) + - [License](#license) + - [Contact](#contact) + +## Technologies + +- **.NET 10** +- SQL Server +- Entity Framework Core + +## Getting started + +### Prerequisites + +- .NET 10 SDK +- SQL Server (Developer, Express, Docker container) +- IDE like VS2022, VS Code or Rider (optional) +- Database management tool like SSMS or Dbeaver (optional) + +### Installation + +> [!NOTE] +> EF Core migrations were created. +> +> Migrations will be applied on startup of the API application, create the database, tables and seed that data from excel document. + +1. Clone the repository + - `git clone https://github.com/nwdorian/PhoneForge.git` + +2. Configure `appsettings.json` + - replace the connection string if necessary + +3. Run the WebApi from project root + - `dotnet run --project ./backend/WebApi/WebApi.csproj` + +4. Run the Console from project root + - `dotnet run --project ./frontend/Console/Console.csproj` + +## Features + +- Contacts management + - Created, delete and update contacts + - Pagination, sorting and filtering + + image + + +- Excel data seeding + - `./documents/contacts.xlsx` + - edit the Excel document to change seed data +- PDF report generation + - outputs reports to `./documents/report-timestamp.pdf` + - applies filtering and sorting options + +## Solution structure + +- Clean Architecture WebAPI project structure + - Rich domain model + - Value Objects enforcing invariants + - CQRS pattern Application layer + - REPR pattern WebAPI layer + - Feature folders oranization per layer + +### Best practices + +- Continuous Integration + - Github Actions workflows for build and test validation +- Consistent code style enforced by `.editorconfig` +- Central build configuration `Directory.Build.props` + - .NET 10 target framework for all projects + - code quality properties +- Central package management `Directory.Packages.Props` +- Static code analysis + - Treat warnings as errors + - Analysis level `latest-Recommended` + - `SonarAnalyzer.CSharp` package +- Api versioning +- Input validation with FluentValidation +- Result pattern +- Structured logging with Serilog +- Soft delete and audit fields +- EF Core configuration classes +- Global exception handling +- ProblemDetails [RFC7808](https://datatracker.ietf.org/doc/html/rfc7807) standard + +## Contributing + +Contributions are welcome! Please fork the repository and create a pull request with your changes. For major changes, please open an issue first to discuss what you would like to change. + +## License + +This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. + +## Contact + +For any questions or feedback, please open an issue. From 38c53f694b84697dbe83a9f3b0ad36d749847357 Mon Sep 17 00:00:00 2001 From: nwdorian <118033138+nwdorian@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:10:58 +0100 Subject: [PATCH 46/46] fix: add condition for positive integer prompt validation (#83) --- frontend/Console/Core/Input/UserInput.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/Console/Core/Input/UserInput.cs b/frontend/Console/Core/Input/UserInput.cs index 1901e78..09956ea 100644 --- a/frontend/Console/Core/Input/UserInput.cs +++ b/frontend/Console/Core/Input/UserInput.cs @@ -71,8 +71,10 @@ public static int PromptPositiveInteger(string displayMessage, bool allowZero) { prompt.Validate(InputValidation.IsGreaterThanOrEqualToZero); } - - prompt.Validate(InputValidation.IsGreaterThanZero); + else + { + prompt.Validate(InputValidation.IsGreaterThanZero); + } return AnsiConsole.Prompt(prompt); }