diff --git a/Directory.Packages.props b/Directory.Packages.props index f90d302..816ac98 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + @@ -22,8 +23,9 @@ - + + diff --git a/README.md b/README.md index 623cb32..6884425 100644 --- a/README.md +++ b/README.md @@ -195,10 +195,6 @@ See [`.cursor/rules/ai-agent.mdc`](.cursor/rules/ai-agent.mdc) for complete codi ## Next Steps -### Phase 2: Domain Layer -- Implement `Lead` entity with validation rules -- Create `ILeadRepository` interface - ### Phase 3: Application Layer - Create DTOs and MediatR commands - Implement FluentValidation validators diff --git a/src/LeadProcessor.Application/Commands/ProcessLeadCommand.cs b/src/LeadProcessor.Application/Commands/ProcessLeadCommand.cs new file mode 100644 index 0000000..dbe9e85 --- /dev/null +++ b/src/LeadProcessor.Application/Commands/ProcessLeadCommand.cs @@ -0,0 +1,62 @@ +using MediatR; + +namespace LeadProcessor.Application.Commands; + +/// +/// Command to process a lead received from the SQS queue. +/// Implements MediatR's IRequest interface for CQRS pattern. +/// +public record ProcessLeadCommand : IRequest +{ + /// + /// Gets the tenant identifier for multi-tenancy support. + /// + public required string TenantId { get; init; } + + /// + /// Gets the correlation identifier for idempotency and message tracking. + /// + public required string CorrelationId { get; init; } + + /// + /// Gets the email address of the lead. + /// + public required string Email { get; init; } + + /// + /// Gets the first name of the lead. + /// + public string? FirstName { get; init; } + + /// + /// Gets the last name of the lead. + /// + public string? LastName { get; init; } + + /// + /// Gets the phone number of the lead. + /// + public string? Phone { get; init; } + + /// + /// Gets the company name of the lead. + /// + public string? Company { get; init; } + + /// + /// Gets the source from which the lead originated. + /// + public required string Source { get; init; } + + /// + /// Gets the metadata as a JSON string containing additional information. + /// + public string? Metadata { get; init; } + + /// + /// Gets the ISO 8601 formatted timestamp when the message was sent. + /// Used for message tracking and debugging. + /// + public string? MessageTimestamp { get; init; } +} + diff --git a/src/LeadProcessor.Application/DTOs/LeadCreatedEvent.cs b/src/LeadProcessor.Application/DTOs/LeadCreatedEvent.cs new file mode 100644 index 0000000..9d6c166 --- /dev/null +++ b/src/LeadProcessor.Application/DTOs/LeadCreatedEvent.cs @@ -0,0 +1,61 @@ +namespace LeadProcessor.Application.DTOs; + +/// +/// Represents a lead creation event received from the SQS message queue. +/// This DTO maps to the message structure sent by the PHP gateway. +/// +public record LeadCreatedEvent +{ + /// + /// Gets the tenant identifier for multi-tenancy support. + /// + public required string TenantId { get; init; } + + /// + /// Gets the correlation identifier for idempotency and message tracking. + /// Must be unique per message to prevent duplicate processing. + /// + public required string CorrelationId { get; init; } + + /// + /// Gets the email address of the lead. + /// + public required string Email { get; init; } + + /// + /// Gets the first name of the lead. + /// + public string? FirstName { get; init; } + + /// + /// Gets the last name of the lead. + /// + public string? LastName { get; init; } + + /// + /// Gets the phone number of the lead. + /// + public string? Phone { get; init; } + + /// + /// Gets the company name of the lead. + /// + public string? Company { get; init; } + + /// + /// Gets the source from which the lead originated (e.g., website, mobile app, referral). + /// + public required string Source { get; init; } + + /// + /// Gets the metadata as a JSON string containing additional information about the lead. + /// + public string? Metadata { get; init; } + + /// + /// Gets the ISO 8601 formatted timestamp when the message was sent. + /// This will be parsed to DateTimeOffset in the handler. + /// + public string? MessageTimestamp { get; init; } +} + diff --git a/src/LeadProcessor.Application/DependencyInjection/ServiceCollectionExtensions.cs b/src/LeadProcessor.Application/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..234959b --- /dev/null +++ b/src/LeadProcessor.Application/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace LeadProcessor.Application.DependencyInjection; + +/// +/// Extension methods for configuring application services in the dependency injection container. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds Application layer services to the dependency injection container. + /// This includes MediatR, FluentValidation, and all command handlers and validators. + /// + /// The service collection to add services to. + /// The service collection for method chaining. + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + var assembly = Assembly.GetExecutingAssembly(); + + // Register MediatR with all handlers from this assembly + services.AddMediatR(config => + { + config.RegisterServicesFromAssembly(assembly); + }); + + // Register FluentValidation validators from this assembly + services.AddValidatorsFromAssembly(assembly); + + return services; + } +} + diff --git a/src/LeadProcessor.Application/Handlers/ProcessLeadCommandHandler.cs b/src/LeadProcessor.Application/Handlers/ProcessLeadCommandHandler.cs new file mode 100644 index 0000000..0d7a286 --- /dev/null +++ b/src/LeadProcessor.Application/Handlers/ProcessLeadCommandHandler.cs @@ -0,0 +1,142 @@ +using FluentValidation; +using LeadProcessor.Application.Commands; +using LeadProcessor.Domain.Entities; +using LeadProcessor.Domain.Exceptions; +using LeadProcessor.Domain.Repositories; +using LeadProcessor.Domain.Services; +using MediatR; +using Microsoft.Extensions.Logging; +using System.Globalization; + +namespace LeadProcessor.Application.Handlers; + +/// +/// Handler for processing lead commands. +/// Implements idempotency, validation, and persistence logic for incoming leads. +/// +public class ProcessLeadCommandHandler( + ILeadRepository repository, + IDateTimeProvider dateTimeProvider, + IValidator validator, + ILogger logger) : IRequestHandler +{ + /// + /// Handles the ProcessLeadCommand by validating, checking for duplicates, and persisting the lead. + /// + /// The command containing lead data. + /// Cancellation token to cancel the operation. + /// Unit value indicating successful completion. + /// Thrown when the command fails validation. + /// Thrown when a lead with the same correlation ID already exists. + public async Task Handle(ProcessLeadCommand request, CancellationToken cancellationToken) + { + logger.LogInformation( + "Processing lead command for correlation ID {CorrelationId}, tenant {TenantId}, email {Email}", + request.CorrelationId, + request.TenantId, + request.Email); + + try + { + // 1. Validate the command + var validationResult = await validator.ValidateAsync(request, cancellationToken); + if (!validationResult.IsValid) + { + logger.LogWarning( + "Validation failed for correlation ID {CorrelationId}: {Errors}", + request.CorrelationId, + string.Join(", ", validationResult.Errors.Select(e => e.ErrorMessage))); + + throw new ValidationException(validationResult.Errors); + } + + // 2. Check for idempotency - prevent duplicate processing + var exists = await repository.ExistsByCorrelationIdAsync(request.CorrelationId, cancellationToken); + if (exists) + { + logger.LogInformation( + "Lead with correlation ID {CorrelationId} already exists. Skipping duplicate processing.", + request.CorrelationId); + + throw new DuplicateLeadException(request.CorrelationId); + } + + // 3. Get current timestamp for entity creation + var now = dateTimeProvider.UtcNow; + + // 4. Parse message timestamp for future audit trail enhancement + DateTimeOffset? messageTimestamp = null; + if (!string.IsNullOrWhiteSpace(request.MessageTimestamp)) + { + if (DateTimeOffset.TryParse( + request.MessageTimestamp, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var parsedTimestamp)) + { + messageTimestamp = parsedTimestamp; + logger.LogDebug( + "Parsed message timestamp {MessageTimestamp} for correlation ID {CorrelationId}", + messageTimestamp, + request.CorrelationId); + } + else + { + logger.LogWarning( + "Failed to parse message timestamp '{MessageTimestamp}' for correlation ID {CorrelationId}", + request.MessageTimestamp, + request.CorrelationId); + } + } + + // 5. Map command to domain entity + var lead = new Lead + { + TenantId = request.TenantId, + CorrelationId = request.CorrelationId, + Email = request.Email, + FirstName = request.FirstName, + LastName = request.LastName, + Phone = request.Phone, + Company = request.Company, + Source = request.Source, + Metadata = request.Metadata, + CreatedAt = now, + UpdatedAt = now + }; + + // 6. Persist to repository + var savedLead = await repository.SaveLeadAsync(lead, cancellationToken); + + logger.LogInformation( + "Successfully processed lead {LeadId} for correlation ID {CorrelationId}, tenant {TenantId}", + savedLead.Id, + savedLead.CorrelationId, + savedLead.TenantId); + + return Unit.Value; + } + catch (ValidationException) + { + // Re-throw validation exceptions without logging as error + // (already logged as warning above) + throw; + } + catch (DuplicateLeadException) + { + // Re-throw duplicate exceptions without logging as error + // (already logged as information above - this is expected behavior) + throw; + } + catch (Exception ex) + { + logger.LogError( + ex, + "Unexpected error processing lead for correlation ID {CorrelationId}, tenant {TenantId}", + request.CorrelationId, + request.TenantId); + throw; + } + } +} + diff --git a/src/LeadProcessor.Application/LeadProcessor.Application.csproj b/src/LeadProcessor.Application/LeadProcessor.Application.csproj index e7b881c..cb38f81 100644 --- a/src/LeadProcessor.Application/LeadProcessor.Application.csproj +++ b/src/LeadProcessor.Application/LeadProcessor.Application.csproj @@ -8,7 +8,9 @@ + + diff --git a/src/LeadProcessor.Application/Validators/ProcessLeadCommandValidator.cs b/src/LeadProcessor.Application/Validators/ProcessLeadCommandValidator.cs new file mode 100644 index 0000000..ec482df --- /dev/null +++ b/src/LeadProcessor.Application/Validators/ProcessLeadCommandValidator.cs @@ -0,0 +1,171 @@ +using FluentValidation; +using LeadProcessor.Application.Commands; +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace LeadProcessor.Application.Validators; + +/// +/// Validator for ProcessLeadCommand using FluentValidation. +/// Ensures all lead data meets required business rules before processing. +/// +public partial class ProcessLeadCommandValidator : AbstractValidator +{ + private const int MaxEmailLength = 254; // RFC 5321 + private const int MaxPhoneLength = 20; + private const int MaxNameLength = 100; + private const int MaxCompanyLength = 200; + private const int MaxSourceLength = 50; + private const int MaxTenantIdLength = 100; + private const int MaxMetadataLength = 4000; // Reasonable JSON limit + + /// + /// Initializes a new instance of the ProcessLeadCommandValidator. + /// + public ProcessLeadCommandValidator() + { + // TenantId validation + RuleFor(x => x.TenantId) + .NotEmpty() + .WithMessage("TenantId is required") + .MaximumLength(MaxTenantIdLength) + .WithMessage($"TenantId must not exceed {MaxTenantIdLength} characters"); + + // CorrelationId validation + RuleFor(x => x.CorrelationId) + .NotEmpty() + .WithMessage("CorrelationId is required") + .Must(BeValidGuid) + .WithMessage("CorrelationId must be a valid GUID"); + + // Email validation + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required") + .MaximumLength(MaxEmailLength) + .WithMessage($"Email must not exceed {MaxEmailLength} characters") + .EmailAddress() + .WithMessage("Email must be a valid email address"); + + // FirstName validation + RuleFor(x => x.FirstName) + .MaximumLength(MaxNameLength) + .When(x => !string.IsNullOrEmpty(x.FirstName)) + .WithMessage($"FirstName must not exceed {MaxNameLength} characters"); + + // LastName validation + RuleFor(x => x.LastName) + .MaximumLength(MaxNameLength) + .When(x => !string.IsNullOrEmpty(x.LastName)) + .WithMessage($"LastName must not exceed {MaxNameLength} characters"); + + // Phone validation + RuleFor(x => x.Phone) + .MaximumLength(MaxPhoneLength) + .When(x => !string.IsNullOrEmpty(x.Phone)) + .WithMessage($"Phone must not exceed {MaxPhoneLength} characters") + .Must(BeValidPhoneNumber) + .When(x => !string.IsNullOrEmpty(x.Phone)) + .WithMessage("Phone must be a valid phone number format"); + + // Company validation + RuleFor(x => x.Company) + .MaximumLength(MaxCompanyLength) + .When(x => !string.IsNullOrEmpty(x.Company)) + .WithMessage($"Company must not exceed {MaxCompanyLength} characters"); + + // Source validation + RuleFor(x => x.Source) + .NotEmpty() + .WithMessage("Source is required") + .MaximumLength(MaxSourceLength) + .WithMessage($"Source must not exceed {MaxSourceLength} characters"); + + // Metadata validation + RuleFor(x => x.Metadata) + .MaximumLength(MaxMetadataLength) + .When(x => !string.IsNullOrEmpty(x.Metadata)) + .WithMessage($"Metadata must not exceed {MaxMetadataLength} characters") + .Must(BeValidJson) + .When(x => !string.IsNullOrEmpty(x.Metadata)) + .WithMessage("Metadata must be valid JSON when provided"); + + // MessageTimestamp validation + RuleFor(x => x.MessageTimestamp) + .Must(BeValidIso8601DateTime) + .When(x => !string.IsNullOrEmpty(x.MessageTimestamp)) + .WithMessage("MessageTimestamp must be a valid ISO 8601 date-time string"); + } + + /// + /// Validates that the string is a valid GUID. + /// + private static bool BeValidGuid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return Guid.TryParse(value, out _); + } + + /// + /// Validates that the string is valid JSON. + /// + private static bool BeValidJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return true; + + try + { + using var document = JsonDocument.Parse(json); + return true; + } + catch (JsonException) + { + return false; + } + } + + /// + /// Validates that the string is a valid ISO 8601 date-time. + /// + private static bool BeValidIso8601DateTime(string? timestamp) + { + if (string.IsNullOrWhiteSpace(timestamp)) + return true; + + return DateTimeOffset.TryParse( + timestamp, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AllowWhiteSpaces, + out _); + } + + /// + /// Validates phone number format. + /// Accepts international formats with optional country code, spaces, dashes, and parentheses. + /// + private static bool BeValidPhoneNumber(string? phone) + { + if (string.IsNullOrWhiteSpace(phone)) + return true; + + // Pattern allows: + // - Optional + at start (international) + // - Digits, spaces, dashes, parentheses + // - At least 7 digits (minimum valid phone number) + var pattern = PhoneNumberRegex(); + if (!pattern.IsMatch(phone)) + return false; + + // Ensure at least 7 digits exist + var digitCount = phone.Count(char.IsDigit); + return digitCount >= 7; + } + + [GeneratedRegex(@"^[\+]?[\d\s\-\(\)]+$", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + private static partial Regex PhoneNumberRegex(); +} + diff --git a/src/LeadProcessor.Domain/Exceptions/DuplicateLeadException.cs b/src/LeadProcessor.Domain/Exceptions/DuplicateLeadException.cs new file mode 100644 index 0000000..df01314 --- /dev/null +++ b/src/LeadProcessor.Domain/Exceptions/DuplicateLeadException.cs @@ -0,0 +1,47 @@ +namespace LeadProcessor.Domain.Exceptions; + +/// +/// Exception thrown when a duplicate lead is detected based on correlation ID. +/// This indicates an idempotency violation where the same message is being processed multiple times. +/// +public class DuplicateLeadException : Exception +{ + /// + /// Gets the correlation ID that caused the duplicate detection. + /// + public string CorrelationId { get; } + + /// + /// Initializes a new instance of the DuplicateLeadException class. + /// + /// The correlation ID that already exists. + public DuplicateLeadException(string correlationId) + : base($"A lead with correlation ID '{correlationId}' already exists.") + { + CorrelationId = correlationId; + } + + /// + /// Initializes a new instance of the DuplicateLeadException class with a custom message. + /// + /// The correlation ID that already exists. + /// The custom error message. + public DuplicateLeadException(string correlationId, string message) + : base(message) + { + CorrelationId = correlationId; + } + + /// + /// Initializes a new instance of the DuplicateLeadException class with a custom message and inner exception. + /// + /// The correlation ID that already exists. + /// The custom error message. + /// The inner exception. + public DuplicateLeadException(string correlationId, string message, Exception innerException) + : base(message, innerException) + { + CorrelationId = correlationId; + } +} + diff --git a/src/LeadProcessor.Domain/Services/IDateTimeProvider.cs b/src/LeadProcessor.Domain/Services/IDateTimeProvider.cs new file mode 100644 index 0000000..dc99f1d --- /dev/null +++ b/src/LeadProcessor.Domain/Services/IDateTimeProvider.cs @@ -0,0 +1,14 @@ +namespace LeadProcessor.Domain.Services; + +/// +/// Abstraction for providing current date and time. +/// This allows for deterministic testing and UTC time enforcement. +/// +public interface IDateTimeProvider +{ + /// + /// Gets the current UTC date and time as a DateTimeOffset. + /// + DateTimeOffset UtcNow { get; } +} + diff --git a/src/LeadProcessor.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/LeadProcessor.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..e9ca45d --- /dev/null +++ b/src/LeadProcessor.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using LeadProcessor.Domain.Services; +using LeadProcessor.Infrastructure.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace LeadProcessor.Infrastructure.DependencyInjection; + +/// +/// Extension methods for configuring infrastructure services in the dependency injection container. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds Infrastructure layer services to the dependency injection container. + /// This includes date/time providers and other infrastructure services. + /// + /// The service collection to add services to. + /// The service collection for method chaining. + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) + { + // Register date/time provider + services.AddSingleton(); + + return services; + } +} + diff --git a/src/LeadProcessor.Infrastructure/Services/SystemDateTimeProvider.cs b/src/LeadProcessor.Infrastructure/Services/SystemDateTimeProvider.cs new file mode 100644 index 0000000..96725eb --- /dev/null +++ b/src/LeadProcessor.Infrastructure/Services/SystemDateTimeProvider.cs @@ -0,0 +1,15 @@ +using LeadProcessor.Domain.Services; + +namespace LeadProcessor.Infrastructure.Services; + +/// +/// System implementation of IDateTimeProvider that returns the actual system time. +/// +public class SystemDateTimeProvider : IDateTimeProvider +{ + /// + /// Gets the current UTC date and time from the system clock. + /// + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} + diff --git a/tests/LeadProcessor.UnitTests/Application/Commands/ProcessLeadCommandTests.cs b/tests/LeadProcessor.UnitTests/Application/Commands/ProcessLeadCommandTests.cs new file mode 100644 index 0000000..64a45ed --- /dev/null +++ b/tests/LeadProcessor.UnitTests/Application/Commands/ProcessLeadCommandTests.cs @@ -0,0 +1,296 @@ +using LeadProcessor.Application.Commands; +using MediatR; + +namespace LeadProcessor.UnitTests.Application.Commands; + +/// +/// Unit tests for the ProcessLeadCommand. +/// +public class ProcessLeadCommandTests +{ + #region Initialization Tests + + [Fact] + public void ProcessLeadCommand_CanBeInitializedWithAllProperties() + { + // Arrange & Act + var correlationId = Guid.NewGuid().ToString(); + var timestamp = DateTimeOffset.UtcNow.ToString("o"); + + var command = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "+1-555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"utm_source\":\"google\"}", + MessageTimestamp = timestamp + }; + + // Assert + Assert.Equal("tenant-123", command.TenantId); + Assert.Equal(correlationId, command.CorrelationId); + Assert.Equal("test@example.com", command.Email); + Assert.Equal("John", command.FirstName); + Assert.Equal("Doe", command.LastName); + Assert.Equal("+1-555-1234", command.Phone); + Assert.Equal("Acme Corp", command.Company); + Assert.Equal("website", command.Source); + Assert.Equal("{\"utm_source\":\"google\"}", command.Metadata); + Assert.Equal(timestamp, command.MessageTimestamp); + } + + [Fact] + public void ProcessLeadCommand_CanBeInitializedWithRequiredFieldsOnly() + { + // Arrange & Act + var command = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Assert + Assert.NotNull(command.TenantId); + Assert.NotNull(command.CorrelationId); + Assert.NotNull(command.Email); + Assert.NotNull(command.Source); + Assert.Null(command.FirstName); + Assert.Null(command.LastName); + Assert.Null(command.Phone); + Assert.Null(command.Company); + Assert.Null(command.Metadata); + Assert.Null(command.MessageTimestamp); + } + + #endregion + + #region MediatR Interface Tests + + [Fact] + public void ProcessLeadCommand_ImplementsIRequest() + { + // Arrange + var command = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Act & Assert + Assert.IsAssignableFrom>(command); + } + + #endregion + + #region Record Equality Tests + + [Fact] + public void ProcessLeadCommand_WithSameValues_AreEqual() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + var timestamp = DateTimeOffset.UtcNow.ToString("o"); + + var command1 = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"key\":\"value\"}", + MessageTimestamp = timestamp + }; + + var command2 = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"key\":\"value\"}", + MessageTimestamp = timestamp + }; + + // Act & Assert + Assert.Equal(command1, command2); + Assert.True(command1 == command2); + Assert.False(command1 != command2); + Assert.Equal(command1.GetHashCode(), command2.GetHashCode()); + } + + [Fact] + public void ProcessLeadCommand_WithDifferentValues_AreNotEqual() + { + // Arrange + var command1 = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test1@example.com", + Source = "website" + }; + + var command2 = new ProcessLeadCommand + { + TenantId = "tenant-456", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test2@example.com", + Source = "mobile" + }; + + // Act & Assert + Assert.NotEqual(command1, command2); + Assert.False(command1 == command2); + Assert.True(command1 != command2); + } + + [Fact] + public void ProcessLeadCommand_WithDifferentCorrelationId_AreNotEqual() + { + // Arrange + var command1 = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + var command2 = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Act & Assert + Assert.NotEqual(command1, command2); + } + + #endregion + + #region Immutability Tests + + [Fact] + public void ProcessLeadCommand_IsImmutable_CannotModifyAfterInitialization() + { + // Arrange + var command = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Act - Create a modified copy using 'with' expression + var modifiedCommand = command with { Email = "modified@example.com" }; + + // Assert - Original is unchanged + Assert.Equal("test@example.com", command.Email); + Assert.Equal("modified@example.com", modifiedCommand.Email); + Assert.NotEqual(command, modifiedCommand); + } + + [Fact] + public void ProcessLeadCommand_WithExpression_CreatesNewInstance() + { + // Arrange + var original = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + FirstName = "John", + Source = "website" + }; + + // Act + var modified = original with { FirstName = "Jane" }; + + // Assert + Assert.NotSame(original, modified); + Assert.Equal("John", original.FirstName); + Assert.Equal("Jane", modified.FirstName); + Assert.Equal(original.Email, modified.Email); + Assert.Equal(original.TenantId, modified.TenantId); + } + + [Fact] + public void ProcessLeadCommand_WithExpression_CanModifyMultipleProperties() + { + // Arrange + var original = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Source = "website" + }; + + // Act + var modified = original with + { + FirstName = "Jane", + LastName = "Smith", + Company = "New Corp" + }; + + // Assert + Assert.Equal("John", original.FirstName); + Assert.Equal("Doe", original.LastName); + Assert.Null(original.Company); + + Assert.Equal("Jane", modified.FirstName); + Assert.Equal("Smith", modified.LastName); + Assert.Equal("New Corp", modified.Company); + } + + #endregion + + #region ToString Tests + + [Fact] + public void ProcessLeadCommand_ToString_ContainsPropertyValues() + { + // Arrange + var command = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Act + var result = command.ToString(); + + // Assert + Assert.Contains("tenant-123", result); + Assert.Contains("test@example.com", result); + Assert.Contains("website", result); + } + + #endregion +} + diff --git a/tests/LeadProcessor.UnitTests/Application/DTOs/LeadCreatedEventTests.cs b/tests/LeadProcessor.UnitTests/Application/DTOs/LeadCreatedEventTests.cs new file mode 100644 index 0000000..f7f7301 --- /dev/null +++ b/tests/LeadProcessor.UnitTests/Application/DTOs/LeadCreatedEventTests.cs @@ -0,0 +1,245 @@ +using LeadProcessor.Application.DTOs; + +namespace LeadProcessor.UnitTests.Application.DTOs; + +/// +/// Unit tests for the LeadCreatedEvent DTO. +/// +public class LeadCreatedEventTests +{ + #region Initialization Tests + + [Fact] + public void LeadCreatedEvent_CanBeInitializedWithAllProperties() + { + // Arrange & Act + var correlationId = Guid.NewGuid().ToString(); + var timestamp = DateTimeOffset.UtcNow.ToString("o"); + + var leadEvent = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "+1-555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"utm_source\":\"google\"}", + MessageTimestamp = timestamp + }; + + // Assert + Assert.Equal("tenant-123", leadEvent.TenantId); + Assert.Equal(correlationId, leadEvent.CorrelationId); + Assert.Equal("test@example.com", leadEvent.Email); + Assert.Equal("John", leadEvent.FirstName); + Assert.Equal("Doe", leadEvent.LastName); + Assert.Equal("+1-555-1234", leadEvent.Phone); + Assert.Equal("Acme Corp", leadEvent.Company); + Assert.Equal("website", leadEvent.Source); + Assert.Equal("{\"utm_source\":\"google\"}", leadEvent.Metadata); + Assert.Equal(timestamp, leadEvent.MessageTimestamp); + } + + [Fact] + public void LeadCreatedEvent_CanBeInitializedWithRequiredFieldsOnly() + { + // Arrange & Act + var leadEvent = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Assert + Assert.NotNull(leadEvent.TenantId); + Assert.NotNull(leadEvent.CorrelationId); + Assert.NotNull(leadEvent.Email); + Assert.NotNull(leadEvent.Source); + Assert.Null(leadEvent.FirstName); + Assert.Null(leadEvent.LastName); + Assert.Null(leadEvent.Phone); + Assert.Null(leadEvent.Company); + Assert.Null(leadEvent.Metadata); + Assert.Null(leadEvent.MessageTimestamp); + } + + #endregion + + #region Record Equality Tests + + [Fact] + public void LeadCreatedEvent_WithSameValues_AreEqual() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + var timestamp = DateTimeOffset.UtcNow.ToString("o"); + + var event1 = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"key\":\"value\"}", + MessageTimestamp = timestamp + }; + + var event2 = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"key\":\"value\"}", + MessageTimestamp = timestamp + }; + + // Act & Assert + Assert.Equal(event1, event2); + Assert.True(event1 == event2); + Assert.False(event1 != event2); + Assert.Equal(event1.GetHashCode(), event2.GetHashCode()); + } + + [Fact] + public void LeadCreatedEvent_WithDifferentValues_AreNotEqual() + { + // Arrange + var event1 = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test1@example.com", + Source = "website" + }; + + var event2 = new LeadCreatedEvent + { + TenantId = "tenant-456", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test2@example.com", + Source = "mobile" + }; + + // Act & Assert + Assert.NotEqual(event1, event2); + Assert.False(event1 == event2); + Assert.True(event1 != event2); + } + + [Fact] + public void LeadCreatedEvent_WithDifferentEmail_AreNotEqual() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + + var event1 = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test1@example.com", + Source = "website" + }; + + var event2 = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test2@example.com", + Source = "website" + }; + + // Act & Assert + Assert.NotEqual(event1, event2); + } + + #endregion + + #region Immutability Tests + + [Fact] + public void LeadCreatedEvent_IsImmutable_CannotModifyAfterInitialization() + { + // Arrange + var leadEvent = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Act - Create a modified copy using 'with' expression + var modifiedEvent = leadEvent with { Email = "modified@example.com" }; + + // Assert - Original is unchanged + Assert.Equal("test@example.com", leadEvent.Email); + Assert.Equal("modified@example.com", modifiedEvent.Email); + Assert.NotEqual(leadEvent, modifiedEvent); + } + + [Fact] + public void LeadCreatedEvent_WithExpression_CreatesNewInstance() + { + // Arrange + var original = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + FirstName = "John", + Source = "website" + }; + + // Act + var modified = original with { FirstName = "Jane" }; + + // Assert + Assert.NotSame(original, modified); + Assert.Equal("John", original.FirstName); + Assert.Equal("Jane", modified.FirstName); + Assert.Equal(original.Email, modified.Email); + Assert.Equal(original.TenantId, modified.TenantId); + } + + #endregion + + #region ToString Tests + + [Fact] + public void LeadCreatedEvent_ToString_ContainsPropertyValues() + { + // Arrange + var leadEvent = new LeadCreatedEvent + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Act + var result = leadEvent.ToString(); + + // Assert + Assert.Contains("tenant-123", result); + Assert.Contains("test@example.com", result); + Assert.Contains("website", result); + } + + #endregion +} + diff --git a/tests/LeadProcessor.UnitTests/Application/Handlers/ProcessLeadCommandHandlerTests.cs b/tests/LeadProcessor.UnitTests/Application/Handlers/ProcessLeadCommandHandlerTests.cs new file mode 100644 index 0000000..d825024 --- /dev/null +++ b/tests/LeadProcessor.UnitTests/Application/Handlers/ProcessLeadCommandHandlerTests.cs @@ -0,0 +1,492 @@ +using FluentValidation; +using FluentValidation.Results; +using LeadProcessor.Application.Commands; +using LeadProcessor.Application.Handlers; +using LeadProcessor.Domain.Entities; +using LeadProcessor.Domain.Exceptions; +using LeadProcessor.Domain.Repositories; +using LeadProcessor.Domain.Services; +using MediatR; +using Microsoft.Extensions.Logging; +using Moq; + +namespace LeadProcessor.UnitTests.Application.Handlers; + +/// +/// Unit tests for ProcessLeadCommandHandler. +/// +public class ProcessLeadCommandHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly Mock _dateTimeProviderMock; + private readonly Mock> _validatorMock; + private readonly Mock> _loggerMock; + private readonly ProcessLeadCommandHandler _handler; + private readonly DateTimeOffset _fixedDateTime; + + public ProcessLeadCommandHandlerTests() + { + _repositoryMock = new Mock(); + _dateTimeProviderMock = new Mock(); + _validatorMock = new Mock>(); + _loggerMock = new Mock>(); + + _fixedDateTime = new DateTimeOffset(2025, 10, 21, 12, 30, 45, TimeSpan.Zero); + _dateTimeProviderMock.Setup(x => x.UtcNow).Returns(_fixedDateTime); + + _handler = new ProcessLeadCommandHandler( + _repositoryMock.Object, + _dateTimeProviderMock.Object, + _validatorMock.Object, + _loggerMock.Object); + } + + #region Successful Processing Tests + + [Fact] + public async Task Handle_WithValidCommand_ShouldSaveLeadSuccessfully() + { + // Arrange + var command = CreateValidCommand(); + SetupValidationSuccess(); + SetupRepositoryNotExists(); + SetupRepositorySaveSuccess(); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.Equal(Unit.Value, result); + + _validatorMock.Verify(v => v.ValidateAsync(command, It.IsAny()), Times.Once); + _repositoryMock.Verify(r => r.ExistsByCorrelationIdAsync(command.CorrelationId, It.IsAny()), Times.Once); + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldMapAllFieldsToLead() + { + // Arrange + var command = CreateValidCommand(); + Lead? capturedLead = null; + + SetupValidationSuccess(); + SetupRepositoryNotExists(); + _repositoryMock + .Setup(r => r.SaveLeadAsync(It.IsAny(), It.IsAny())) + .Callback((lead, _) => capturedLead = lead) + .ReturnsAsync((Lead lead, CancellationToken _) => lead with { Id = 42 }); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.NotNull(capturedLead); + Assert.Equal(command.TenantId, capturedLead.TenantId); + Assert.Equal(command.CorrelationId, capturedLead.CorrelationId); + Assert.Equal(command.Email, capturedLead.Email); + Assert.Equal(command.FirstName, capturedLead.FirstName); + Assert.Equal(command.LastName, capturedLead.LastName); + Assert.Equal(command.Phone, capturedLead.Phone); + Assert.Equal(command.Company, capturedLead.Company); + Assert.Equal(command.Source, capturedLead.Source); + Assert.Equal(command.Metadata, capturedLead.Metadata); + Assert.Equal(_fixedDateTime, capturedLead.CreatedAt); + Assert.Equal(_fixedDateTime, capturedLead.UpdatedAt); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldUseFixedTimestampFromProvider() + { + // Arrange + var command = CreateValidCommand(); + Lead? capturedLead = null; + + SetupValidationSuccess(); + SetupRepositoryNotExists(); + _repositoryMock + .Setup(r => r.SaveLeadAsync(It.IsAny(), It.IsAny())) + .Callback((lead, _) => capturedLead = lead) + .ReturnsAsync((Lead lead, CancellationToken _) => lead with { Id = 42 }); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.NotNull(capturedLead); + Assert.Equal(_fixedDateTime, capturedLead.CreatedAt); + Assert.Equal(_fixedDateTime, capturedLead.UpdatedAt); + _dateTimeProviderMock.Verify(d => d.UtcNow, Times.Once); + } + + [Fact] + public async Task Handle_WithMinimalCommand_ShouldSaveWithNullOptionalFields() + { + // Arrange + var command = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + Lead? capturedLead = null; + + SetupValidationSuccess(); + SetupRepositoryNotExists(); + _repositoryMock + .Setup(r => r.SaveLeadAsync(It.IsAny(), It.IsAny())) + .Callback((lead, _) => capturedLead = lead) + .ReturnsAsync((Lead lead, CancellationToken _) => lead with { Id = 42 }); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.NotNull(capturedLead); + Assert.Null(capturedLead.FirstName); + Assert.Null(capturedLead.LastName); + Assert.Null(capturedLead.Phone); + Assert.Null(capturedLead.Company); + Assert.Null(capturedLead.Metadata); + } + + [Fact] + public async Task Handle_WithValidMessageTimestamp_ShouldLogTimestampParsing() + { + // Arrange + var command = CreateValidCommand() with + { + MessageTimestamp = "2025-10-21T12:00:00Z" + }; + SetupValidationSuccess(); + SetupRepositoryNotExists(); + SetupRepositorySaveSuccess(); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert - handler should process successfully and log the timestamp + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + #endregion + + #region Validation Tests + + [Fact] + public async Task Handle_WithInvalidCommand_ShouldThrowValidationException() + { + // Arrange + var command = CreateValidCommand(); + var validationErrors = new List + { + new ValidationFailure("Email", "Email is required") + }; + SetupValidationFailure(validationErrors); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + + _repositoryMock.Verify(r => r.ExistsByCorrelationIdAsync(It.IsAny(), It.IsAny()), Times.Never); + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithValidationErrors_ShouldNotPersistLead() + { + // Arrange + var command = CreateValidCommand(); + var validationErrors = new List + { + new ValidationFailure("Email", "Invalid email format"), + new ValidationFailure("Phone", "Invalid phone format") + }; + SetupValidationFailure(validationErrors); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion + + #region Idempotency Tests + + [Fact] + public async Task Handle_WithDuplicateCorrelationId_ShouldThrowDuplicateLeadException() + { + // Arrange + var command = CreateValidCommand(); + SetupValidationSuccess(); + _repositoryMock + .Setup(r => r.ExistsByCorrelationIdAsync(command.CorrelationId, It.IsAny())) + .ReturnsAsync(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + + Assert.Equal(command.CorrelationId, exception.CorrelationId); + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithDuplicateCorrelationId_ShouldNotAttemptSave() + { + // Arrange + var command = CreateValidCommand(); + SetupValidationSuccess(); + _repositoryMock + .Setup(r => r.ExistsByCorrelationIdAsync(command.CorrelationId, It.IsAny())) + .ReturnsAsync(true); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_ChecksIdempotencyBeforeSaving() + { + // Arrange + var command = CreateValidCommand(); + var callOrder = new List(); + + SetupValidationSuccess(); + + _repositoryMock + .Setup(r => r.ExistsByCorrelationIdAsync(command.CorrelationId, It.IsAny())) + .ReturnsAsync(false) + .Callback(() => callOrder.Add("ExistsByCorrelationId")); + + _repositoryMock + .Setup(r => r.SaveLeadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Lead lead, CancellationToken _) => lead with { Id = 42 }) + .Callback(() => callOrder.Add("SaveLead")); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.Equal(2, callOrder.Count); + Assert.Equal("ExistsByCorrelationId", callOrder[0]); + Assert.Equal("SaveLead", callOrder[1]); + } + + #endregion + + #region Exception Handling Tests + + [Fact] + public async Task Handle_WhenRepositoryThrowsException_ShouldPropagateException() + { + // Arrange + var command = CreateValidCommand(); + SetupValidationSuccess(); + SetupRepositoryNotExists(); + + _repositoryMock + .Setup(r => r.SaveLeadAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Database error")); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + } + + [Fact] + public async Task Handle_WhenValidatorThrowsException_ShouldPropagateException() + { + // Arrange + var command = CreateValidCommand(); + _validatorMock + .Setup(v => v.ValidateAsync(command, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Validator error")); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion + + #region Cancellation Tests + + [Fact] + public async Task Handle_WhenCancellationRequested_ShouldPassCancellationToken() + { + // Arrange + var command = CreateValidCommand(); + var cts = new CancellationTokenSource(); + + SetupValidationSuccess(); + SetupRepositoryNotExists(); + + // Cancel after setup but before execution + cts.Cancel(); + + _repositoryMock + .Setup(r => r.SaveLeadAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // Act & Assert - should propagate cancellation + await Assert.ThrowsAsync(async () => + { + await _handler.Handle(command, cts.Token); + }); + } + + [Fact] + public async Task Handle_ShouldPassCancellationTokenToAllAsyncOperations() + { + // Arrange + var command = CreateValidCommand(); + var cancellationToken = new CancellationToken(); + CancellationToken capturedValidationToken = default; + CancellationToken capturedExistsToken = default; + CancellationToken capturedSaveToken = default; + + _validatorMock + .Setup(v => v.ValidateAsync(command, It.IsAny())) + .Callback((_, token) => capturedValidationToken = token) + .ReturnsAsync(new ValidationResult()); + + _repositoryMock + .Setup(r => r.ExistsByCorrelationIdAsync(command.CorrelationId, It.IsAny())) + .Callback((_, token) => capturedExistsToken = token) + .ReturnsAsync(false); + + _repositoryMock + .Setup(r => r.SaveLeadAsync(It.IsAny(), It.IsAny())) + .Callback((_, token) => capturedSaveToken = token) + .ReturnsAsync((Lead lead, CancellationToken _) => lead with { Id = 42 }); + + // Act + await _handler.Handle(command, cancellationToken); + + // Assert + Assert.Equal(cancellationToken, capturedValidationToken); + Assert.Equal(cancellationToken, capturedExistsToken); + Assert.Equal(cancellationToken, capturedSaveToken); + } + + #endregion + + #region Message Timestamp Parsing Tests + + [Theory] + [InlineData("2025-10-21T12:30:45Z")] + [InlineData("2025-10-21T12:30:45+00:00")] + [InlineData("2025-10-21T12:30:45.123Z")] + public async Task Handle_WithValidMessageTimestamp_ShouldProcessSuccessfully(string timestamp) + { + // Arrange + var command = CreateValidCommand() with { MessageTimestamp = timestamp }; + SetupValidationSuccess(); + SetupRepositoryNotExists(); + SetupRepositorySaveSuccess(); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.Equal(Unit.Value, result); + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Theory] + [InlineData("invalid-timestamp")] + [InlineData("not-a-date")] + [InlineData("")] + public async Task Handle_WithInvalidMessageTimestamp_ShouldStillProcessSuccessfully(string timestamp) + { + // Arrange + var command = CreateValidCommand() with { MessageTimestamp = timestamp }; + SetupValidationSuccess(); + SetupRepositoryNotExists(); + SetupRepositorySaveSuccess(); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert - invalid timestamp should not prevent processing + Assert.Equal(Unit.Value, result); + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNullMessageTimestamp_ShouldProcessSuccessfully() + { + // Arrange + var command = CreateValidCommand() with { MessageTimestamp = null }; + SetupValidationSuccess(); + SetupRepositoryNotExists(); + SetupRepositorySaveSuccess(); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.Equal(Unit.Value, result); + _repositoryMock.Verify(r => r.SaveLeadAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + #endregion + + #region Helper Methods + + private static ProcessLeadCommand CreateValidCommand() + { + return new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "+1-555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"utm_source\":\"google\"}" + }; + } + + private void SetupValidationSuccess() + { + _validatorMock + .Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult()); + } + + private void SetupValidationFailure(List errors) + { + _validatorMock + .Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(errors)); + } + + private void SetupRepositoryNotExists() + { + _repositoryMock + .Setup(r => r.ExistsByCorrelationIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + } + + private void SetupRepositorySaveSuccess() + { + _repositoryMock + .Setup(r => r.SaveLeadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Lead lead, CancellationToken _) => lead with { Id = 42 }); + } + + #endregion +} + diff --git a/tests/LeadProcessor.UnitTests/Application/Validators/ProcessLeadCommandValidatorTests.cs b/tests/LeadProcessor.UnitTests/Application/Validators/ProcessLeadCommandValidatorTests.cs new file mode 100644 index 0000000..9e40a5e --- /dev/null +++ b/tests/LeadProcessor.UnitTests/Application/Validators/ProcessLeadCommandValidatorTests.cs @@ -0,0 +1,609 @@ +using FluentValidation.TestHelper; +using LeadProcessor.Application.Commands; +using LeadProcessor.Application.Validators; + +namespace LeadProcessor.UnitTests.Application.Validators; + +/// +/// Unit tests for ProcessLeadCommandValidator. +/// +public class ProcessLeadCommandValidatorTests +{ + private readonly ProcessLeadCommandValidator _validator; + + public ProcessLeadCommandValidatorTests() + { + _validator = new ProcessLeadCommandValidator(); + } + + #region TenantId Validation Tests + + [Fact] + public void Validate_WithEmptyTenantId_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { TenantId = string.Empty }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.TenantId) + .WithErrorMessage("TenantId is required"); + } + + [Fact] + public void Validate_WithTooLongTenantId_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { TenantId = new string('a', 101) }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.TenantId) + .WithErrorMessage("TenantId must not exceed 100 characters"); + } + + [Fact] + public void Validate_WithValidTenantId_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { TenantId = "tenant-123" }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.TenantId); + } + + #endregion + + #region CorrelationId Validation Tests + + [Fact] + public void Validate_WithEmptyCorrelationId_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { CorrelationId = string.Empty }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.CorrelationId) + .WithErrorMessage("CorrelationId is required"); + } + + [Theory] + [InlineData("not-a-guid")] + [InlineData("123456")] + [InlineData("invalid")] + [InlineData("00000000-0000-0000-0000-00000000000G")] + public void Validate_WithInvalidGuidCorrelationId_ShouldHaveValidationError(string invalidGuid) + { + // Arrange + var command = CreateValidCommand() with { CorrelationId = invalidGuid }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.CorrelationId) + .WithErrorMessage("CorrelationId must be a valid GUID"); + } + + [Fact] + public void Validate_WithValidGuidCorrelationId_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { CorrelationId = Guid.NewGuid().ToString() }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.CorrelationId); + } + + #endregion + + #region Email Validation Tests + + [Fact] + public void Validate_WithEmptyEmail_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Email = string.Empty }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Email is required"); + } + + [Theory] + [InlineData("invalid-email")] + [InlineData("@example.com")] + [InlineData("user@")] + public void Validate_WithInvalidEmail_ShouldHaveValidationError(string invalidEmail) + { + // Arrange + var command = CreateValidCommand() with { Email = invalidEmail }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Email must be a valid email address"); + } + + [Theory] + [InlineData("user@example.com")] + [InlineData("test.user+tag@example.co.uk")] + [InlineData("user_name@example-domain.com")] + [InlineData("123@test.com")] + public void Validate_WithValidEmail_ShouldNotHaveValidationError(string validEmail) + { + // Arrange + var command = CreateValidCommand() with { Email = validEmail }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Email); + } + + [Fact] + public void Validate_WithTooLongEmail_ShouldHaveValidationError() + { + // Arrange + var longEmail = new string('a', 250) + "@test.com"; // Over 254 chars + var command = CreateValidCommand() with { Email = longEmail }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Email must not exceed 254 characters"); + } + + #endregion + + #region FirstName and LastName Validation Tests + + [Fact] + public void Validate_WithTooLongFirstName_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { FirstName = new string('a', 101) }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.FirstName) + .WithErrorMessage("FirstName must not exceed 100 characters"); + } + + [Fact] + public void Validate_WithTooLongLastName_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { LastName = new string('a', 101) }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.LastName) + .WithErrorMessage("LastName must not exceed 100 characters"); + } + + [Fact] + public void Validate_WithValidNames_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { FirstName = "John", LastName = "Doe" }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.FirstName); + result.ShouldNotHaveValidationErrorFor(x => x.LastName); + } + + [Fact] + public void Validate_WithNullNames_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { FirstName = null, LastName = null }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.FirstName); + result.ShouldNotHaveValidationErrorFor(x => x.LastName); + } + + #endregion + + #region Phone Validation Tests + + [Theory] + [InlineData("+1-555-1234")] + [InlineData("555-1234")] + [InlineData("+44 20 7123 4567")] + [InlineData("(555) 123-4567")] + [InlineData("+1 (555) 123-4567")] + [InlineData("1234567")] + [InlineData("+61 2 1234 5678")] + public void Validate_WithValidPhoneNumber_ShouldNotHaveValidationError(string validPhone) + { + // Arrange + var command = CreateValidCommand() with { Phone = validPhone }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Phone); + } + + [Theory] + [InlineData("abc-defg")] + [InlineData("phone#123")] + [InlineData("123")] + [InlineData("12345")] + public void Validate_WithInvalidPhoneNumber_ShouldHaveValidationError(string invalidPhone) + { + // Arrange + var command = CreateValidCommand() with { Phone = invalidPhone }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Phone) + .WithErrorMessage("Phone must be a valid phone number format"); + } + + [Fact] + public void Validate_WithTooLongPhone_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Phone = new string('1', 21) }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Phone) + .WithErrorMessage("Phone must not exceed 20 characters"); + } + + [Fact] + public void Validate_WithNullPhone_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Phone = null }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Phone); + } + + #endregion + + #region Company Validation Tests + + [Fact] + public void Validate_WithTooLongCompany_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Company = new string('a', 201) }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Company) + .WithErrorMessage("Company must not exceed 200 characters"); + } + + [Fact] + public void Validate_WithValidCompany_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Company = "Acme Corp" }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Company); + } + + #endregion + + #region Source Validation Tests + + [Fact] + public void Validate_WithEmptySource_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Source = string.Empty }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Source) + .WithErrorMessage("Source is required"); + } + + [Fact] + public void Validate_WithTooLongSource_ShouldHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Source = new string('a', 51) }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Source) + .WithErrorMessage("Source must not exceed 50 characters"); + } + + [Fact] + public void Validate_WithValidSource_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Source = "website" }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Source); + } + + #endregion + + #region Metadata Validation Tests + + [Fact] + public void Validate_WithValidJsonMetadata_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Metadata = "{\"key\":\"value\",\"number\":123}" }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Metadata); + } + + [Theory] + [InlineData("{\"key\":\"value\"}")] + [InlineData("[]")] + [InlineData("[1,2,3]")] + [InlineData("{\"nested\":{\"key\":\"value\"}}")] + public void Validate_WithVariousValidJsonFormats_ShouldNotHaveValidationError(string validJson) + { + // Arrange + var command = CreateValidCommand() with { Metadata = validJson }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Metadata); + } + + [Theory] + [InlineData("invalid json")] + [InlineData("{key:value}")] + [InlineData("{'key':'value'}")] + [InlineData("{\"key\":}")] + public void Validate_WithInvalidJsonMetadata_ShouldHaveValidationError(string invalidJson) + { + // Arrange + var command = CreateValidCommand() with { Metadata = invalidJson }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Metadata) + .WithErrorMessage("Metadata must be valid JSON when provided"); + } + + [Fact] + public void Validate_WithTooLongMetadata_ShouldHaveValidationError() + { + // Arrange + var longMetadata = "{\"data\":\"" + new string('a', 4000) + "\"}"; + var command = CreateValidCommand() with { Metadata = longMetadata }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Metadata) + .WithErrorMessage("Metadata must not exceed 4000 characters"); + } + + [Fact] + public void Validate_WithNullMetadata_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { Metadata = null }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.Metadata); + } + + #endregion + + #region MessageTimestamp Validation Tests + + [Theory] + [InlineData("2025-10-21T12:30:45Z")] + [InlineData("2025-10-21T12:30:45+00:00")] + [InlineData("2025-10-21T12:30:45.123Z")] + [InlineData("2025-10-21T12:30:45-05:00")] + public void Validate_WithValidIso8601Timestamp_ShouldNotHaveValidationError(string validTimestamp) + { + // Arrange + var command = CreateValidCommand() with { MessageTimestamp = validTimestamp }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.MessageTimestamp); + } + + [Theory] + [InlineData("invalid-date")] + [InlineData("not a date")] + [InlineData("20251021")] + [InlineData("2025-13-45")] + public void Validate_WithInvalidTimestamp_ShouldHaveValidationError(string invalidTimestamp) + { + // Arrange + var command = CreateValidCommand() with { MessageTimestamp = invalidTimestamp }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.MessageTimestamp) + .WithErrorMessage("MessageTimestamp must be a valid ISO 8601 date-time string"); + } + + [Fact] + public void Validate_WithNullMessageTimestamp_ShouldNotHaveValidationError() + { + // Arrange + var command = CreateValidCommand() with { MessageTimestamp = null }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.MessageTimestamp); + } + + #endregion + + #region Complete Command Validation Tests + + [Fact] + public void Validate_WithCompletelyValidCommand_ShouldNotHaveAnyValidationErrors() + { + // Arrange + var command = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "+1-555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"utm_source\":\"google\"}", + MessageTimestamp = DateTimeOffset.UtcNow.ToString("o") + }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_WithMinimumValidCommand_ShouldNotHaveAnyValidationErrors() + { + // Arrange + var command = new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_WithMultipleErrors_ShouldHaveAllValidationErrors() + { + // Arrange + var command = new ProcessLeadCommand + { + TenantId = string.Empty, + CorrelationId = "invalid-guid", + Email = "invalid-email", + Source = string.Empty, + Phone = "abc", + Metadata = "invalid json" + }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.TenantId); + result.ShouldHaveValidationErrorFor(x => x.CorrelationId); + result.ShouldHaveValidationErrorFor(x => x.Email); + result.ShouldHaveValidationErrorFor(x => x.Source); + result.ShouldHaveValidationErrorFor(x => x.Phone); + result.ShouldHaveValidationErrorFor(x => x.Metadata); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a valid ProcessLeadCommand for testing. + /// + private static ProcessLeadCommand CreateValidCommand() + { + return new ProcessLeadCommand + { + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website" + }; + } + + #endregion +} + diff --git a/tests/LeadProcessor.UnitTests/Domain/Entities/LeadTests.cs b/tests/LeadProcessor.UnitTests/Domain/Entities/LeadTests.cs new file mode 100644 index 0000000..c058850 --- /dev/null +++ b/tests/LeadProcessor.UnitTests/Domain/Entities/LeadTests.cs @@ -0,0 +1,402 @@ +using LeadProcessor.Domain.Entities; + +namespace LeadProcessor.UnitTests.Domain.Entities; + +/// +/// Unit tests for the Lead domain entity. +/// +public class LeadTests +{ + #region HasValidEmail Tests + + [Theory] + [InlineData("user@example.com", true)] + [InlineData("test.user+tag@example.co.uk", true)] + [InlineData("user_name@example-domain.com", true)] + [InlineData("123@test.com", true)] + [InlineData("invalid-email", false)] + [InlineData("@example.com", false)] + [InlineData("user@", false)] + [InlineData("user @example.com", false)] + [InlineData("user@example .com", false)] + public void HasValidEmail_WithVariousFormats_ReturnsExpectedResult(string email, bool expected) + { + // Arrange + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = email, + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.HasValidEmail(); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void HasValidEmail_WithNullOrWhitespace_ReturnsFalse(string? email) + { + // Arrange + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = email ?? string.Empty, + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.HasValidEmail(); + + // Assert + Assert.False(result); + } + + #endregion + + #region IsCorrelationIdGuid Tests + + [Fact] + public void IsCorrelationIdGuid_WithValidGuid_ReturnsTrue() + { + // Arrange + var validGuid = Guid.NewGuid().ToString(); + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = validGuid, + Email = "test@example.com", + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.IsCorrelationIdGuid(); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("not-a-guid")] + [InlineData("123456")] + [InlineData("")] + [InlineData("00000000-0000-0000-0000-00000000000G")] // Invalid character + public void IsCorrelationIdGuid_WithInvalidGuid_ReturnsFalse(string correlationId) + { + // Arrange + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.IsCorrelationIdGuid(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsCorrelationIdGuid_WithGuidInDifferentFormats_ReturnsTrue() + { + // Arrange - Test various valid GUID formats + var testCases = new[] + { + "6F9619FF-8B86-D011-B42D-00C04FC964FF", // Standard format + "6f9619ff-8b86-d011-b42d-00c04fc964ff", // Lowercase + "6F9619FF8B86D011B42D00C04FC964FF" // Without hyphens + }; + + foreach (var guidString in testCases) + { + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = guidString, + Email = "test@example.com", + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.IsCorrelationIdGuid(); + + // Assert + Assert.True(result, $"Expected {guidString} to be valid GUID"); + } + } + + #endregion + + #region GetFullName Tests + + [Fact] + public void GetFullName_WithBothNames_ReturnsCombinedName() + { + // Arrange + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.GetFullName(); + + // Assert + Assert.Equal("John Doe", result); + } + + [Fact] + public void GetFullName_WithOnlyFirstName_ReturnsFirstName() + { + // Arrange + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + FirstName = "John", + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.GetFullName(); + + // Assert + Assert.Equal("John", result); + } + + [Fact] + public void GetFullName_WithOnlyLastName_ReturnsLastName() + { + // Arrange + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + LastName = "Doe", + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.GetFullName(); + + // Assert + Assert.Equal("Doe", result); + } + + [Fact] + public void GetFullName_WithNoNames_ReturnsNull() + { + // Arrange + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.GetFullName(); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData("", "")] + [InlineData(" ", " ")] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(null, "")] + public void GetFullName_WithEmptyOrWhitespaceNames_ReturnsNull(string? firstName, string? lastName) + { + // Arrange + var lead = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test@example.com", + FirstName = firstName, + LastName = lastName, + Source = "website", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + // Act + var result = lead.GetFullName(); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Record Equality and Immutability Tests + + [Fact] + public void Lead_WithSameValues_AreEqual() + { + // Arrange + var now = DateTimeOffset.UtcNow; + var correlationId = Guid.NewGuid().ToString(); + + var lead1 = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"key\":\"value\"}", + CreatedAt = now, + UpdatedAt = now + }; + + var lead2 = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"key\":\"value\"}", + CreatedAt = now, + UpdatedAt = now + }; + + // Act & Assert + Assert.Equal(lead1, lead2); + Assert.True(lead1 == lead2); + Assert.False(lead1 != lead2); + } + + [Fact] + public void Lead_WithDifferentValues_AreNotEqual() + { + // Arrange + var now = DateTimeOffset.UtcNow; + + var lead1 = new Lead + { + Id = 1, + TenantId = "tenant-123", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test1@example.com", + Source = "website", + CreatedAt = now, + UpdatedAt = now + }; + + var lead2 = new Lead + { + Id = 2, + TenantId = "tenant-456", + CorrelationId = Guid.NewGuid().ToString(), + Email = "test2@example.com", + Source = "mobile", + CreatedAt = now, + UpdatedAt = now + }; + + // Act & Assert + Assert.NotEqual(lead1, lead2); + Assert.False(lead1 == lead2); + Assert.True(lead1 != lead2); + } + + [Fact] + public void Lead_CanBeInitializedWithAllProperties() + { + // Arrange + var now = DateTimeOffset.UtcNow; + var correlationId = Guid.NewGuid().ToString(); + + // Act + var lead = new Lead + { + Id = 42, + TenantId = "tenant-123", + CorrelationId = correlationId, + Email = "test@example.com", + FirstName = "John", + LastName = "Doe", + Phone = "+1-555-1234", + Company = "Acme Corp", + Source = "website", + Metadata = "{\"utm_source\":\"google\"}", + CreatedAt = now, + UpdatedAt = now + }; + + // Assert + Assert.Equal(42, lead.Id); + Assert.Equal("tenant-123", lead.TenantId); + Assert.Equal(correlationId, lead.CorrelationId); + Assert.Equal("test@example.com", lead.Email); + Assert.Equal("John", lead.FirstName); + Assert.Equal("Doe", lead.LastName); + Assert.Equal("+1-555-1234", lead.Phone); + Assert.Equal("Acme Corp", lead.Company); + Assert.Equal("website", lead.Source); + Assert.Equal("{\"utm_source\":\"google\"}", lead.Metadata); + Assert.Equal(now, lead.CreatedAt); + Assert.Equal(now, lead.UpdatedAt); + } + + #endregion +} + diff --git a/tests/LeadProcessor.UnitTests/UnitTest1.cs b/tests/LeadProcessor.UnitTests/UnitTest1.cs deleted file mode 100644 index fe98133..0000000 --- a/tests/LeadProcessor.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace LeadProcessor.UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file