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