diff --git a/Directory.Packages.props b/Directory.Packages.props
index 816ac98..21f46ea 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,12 +11,18 @@
+
-
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 6884425..cd0aec5 100644
--- a/README.md
+++ b/README.md
@@ -195,11 +195,6 @@ See [`.cursor/rules/ai-agent.mdc`](.cursor/rules/ai-agent.mdc) for complete codi
## Next Steps
-### Phase 3: Application Layer
-- Create DTOs and MediatR commands
-- Implement FluentValidation validators
-- Build command handlers
-
### Phase 4: Infrastructure Layer
- Configure EF Core DbContext
- Implement repository pattern
diff --git a/src/LeadProcessor.Infrastructure/Configuration/AwsSettings.cs b/src/LeadProcessor.Infrastructure/Configuration/AwsSettings.cs
new file mode 100644
index 0000000..df2cccd
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Configuration/AwsSettings.cs
@@ -0,0 +1,140 @@
+namespace LeadProcessor.Infrastructure.Configuration;
+
+///
+/// Configuration settings for AWS services (SQS, SecretsManager, RDS).
+///
+///
+/// This class is designed to be bound from configuration via the IOptions pattern.
+/// Use in Startup/Program.cs:
+///
+/// services.Configure<AwsSettings>(configuration.GetSection("AWS"));
+///
+/// All string properties use the 'required' modifier to enforce configuration at startup.
+/// Credentials should be managed via IAM roles (for Lambda) or environment variables (for local development).
+///
+public sealed class AwsSettings
+{
+ ///
+ /// Gets or sets the AWS region for all services.
+ ///
+ ///
+ /// Examples: "us-east-1", "eu-west-1", "ap-southeast-1"
+ /// Should match the region where RDS, SQS, and Secrets Manager resources are provisioned.
+ ///
+ public required string Region { get; set; }
+
+ ///
+ /// Gets or sets the SQS queue URL for receiving messages.
+ ///
+ ///
+ /// Format: "https://sqs.{region}.amazonaws.com/{account-id}/{queue-name}"
+ /// Used by the Lambda function handler to process incoming lead events.
+ ///
+ public required string SqsQueueUrl { get; set; }
+
+ ///
+ /// Gets or sets the SQS Dead Letter Queue (DLQ) URL for failed messages.
+ ///
+ ///
+ /// Format: "https://sqs.{region}.amazonaws.com/{account-id}/{dlq-name}"
+ /// Messages that fail after MaxRetryAttempts are moved to this queue.
+ ///
+ public required string SqsDlqUrl { get; set; }
+
+ ///
+ /// Gets or sets the AWS Secrets Manager secret name for database credentials.
+ ///
+ ///
+ /// Should contain JSON with keys: Server, Port, Database, User, Password
+ /// Example: "leadprocessor/rds/credentials"
+ ///
+ public required string SecretsManagerSecretName { get; set; }
+
+ ///
+ /// Gets or sets the RDS cluster/instance endpoint.
+ ///
+ ///
+ /// Format: "leadprocessor-db.c9akciq32.{region}.rds.amazonaws.com"
+ /// Used to construct the database connection string.
+ ///
+ public required string RdsEndpoint { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of retry attempts for AWS SDK operations.
+ ///
+ ///
+ /// Default is 3 retries for transient AWS errors.
+ /// Set to 0 to disable retries.
+ ///
+ public int MaxRetryAttempts { get; set; } = 3;
+
+ ///
+ /// Gets or sets a value indicating whether to use IAM authentication for RDS.
+ ///
+ ///
+ /// When enabled (true), uses temporary security credentials from IAM role instead of stored password.
+ /// Recommended for production Lambda deployments.
+ /// Default is false for simplicity in development.
+ ///
+ public bool UseIamDatabaseAuthentication { get; set; } = false;
+
+ ///
+ /// Gets or sets the IAM database authentication token lifetime in seconds.
+ ///
+ ///
+ /// Default is 900 seconds (15 minutes).
+ /// Tokens are cached and reused within their lifetime to reduce API calls.
+ ///
+ public int IamTokenLifetimeSeconds { get; set; } = 900;
+
+ ///
+ /// Gets or sets a value indicating whether to validate AWS credentials at startup.
+ ///
+ ///
+ /// When enabled, performs a test call to verify AWS credentials are valid.
+ /// Useful for catching configuration issues early in the Lambda initialization phase.
+ ///
+ public bool ValidateCredentialsAtStartup { get; set; } = true;
+
+ ///
+ /// Validates the configuration settings.
+ ///
+ /// A list of validation errors, empty if valid.
+ ///
+ /// Called during startup to ensure all required settings are present and valid.
+ ///
+ public IEnumerable Validate()
+ {
+ if (string.IsNullOrWhiteSpace(Region))
+ yield return "Region is required";
+
+ if (string.IsNullOrWhiteSpace(SqsQueueUrl))
+ yield return "SqsQueueUrl is required";
+
+ if (string.IsNullOrWhiteSpace(SqsDlqUrl))
+ yield return "SqsDlqUrl is required";
+
+ if (string.IsNullOrWhiteSpace(SecretsManagerSecretName))
+ yield return "SecretsManagerSecretName is required";
+
+ if (string.IsNullOrWhiteSpace(RdsEndpoint))
+ yield return "RdsEndpoint is required";
+
+ if (MaxRetryAttempts < 0)
+ yield return $"MaxRetryAttempts must be >= 0, got {MaxRetryAttempts}";
+
+ if (IamTokenLifetimeSeconds <= 0)
+ yield return $"IamTokenLifetimeSeconds must be > 0, got {IamTokenLifetimeSeconds}";
+
+ // Validate SQS URLs format (only if not null/empty)
+ if (!string.IsNullOrWhiteSpace(SqsQueueUrl) && !SqsQueueUrl.StartsWith("https://sqs."))
+ yield return "SqsQueueUrl should be in format: https://sqs.{region}.amazonaws.com/{account-id}/{queue-name}";
+
+ if (!string.IsNullOrWhiteSpace(SqsDlqUrl) && !SqsDlqUrl.StartsWith("https://sqs."))
+ yield return "SqsDlqUrl should be in format: https://sqs.{region}.amazonaws.com/{account-id}/{dlq-name}";
+
+ // Validate RDS endpoint format (only if not null/empty)
+ if (!string.IsNullOrWhiteSpace(RdsEndpoint) && !RdsEndpoint.Contains(".rds.amazonaws.com"))
+ yield return "RdsEndpoint should be in format: {identifier}.{region}.rds.amazonaws.com";
+ }
+}
diff --git a/src/LeadProcessor.Infrastructure/Configuration/DatabaseSettings.cs b/src/LeadProcessor.Infrastructure/Configuration/DatabaseSettings.cs
new file mode 100644
index 0000000..e2ec028
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Configuration/DatabaseSettings.cs
@@ -0,0 +1,142 @@
+namespace LeadProcessor.Infrastructure.Configuration;
+
+///
+/// Configuration settings for database connections and EF Core behavior.
+///
+///
+/// This class is designed to be bound from configuration via the IOptions pattern.
+/// Use in Startup/Program.cs:
+///
+/// services.Configure<DatabaseSettings>(configuration.GetSection("Database"));
+///
+/// All string properties use the 'required' modifier to enforce configuration at startup.
+///
+public sealed class DatabaseSettings
+{
+ ///
+ /// Gets or sets the database connection string for RDS MySQL database.
+ ///
+ ///
+ /// Should be in format: "Server=hostname;Port=3306;Database=dbname;User=user;Password=password"
+ /// Can be loaded from AWS Secrets Manager or environment-specific appsettings.json
+ ///
+ public required string ConnectionString { get; set; }
+
+ ///
+ /// Gets or sets the database server hostname or endpoint.
+ ///
+ ///
+ /// For RDS: format is typically "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com"
+ /// For local development: "localhost"
+ ///
+ public required string Server { get; set; }
+
+ ///
+ /// Gets or sets the database server port.
+ ///
+ ///
+ /// Default MySQL port is 3306.
+ ///
+ public int Port { get; set; } = 3306;
+
+ ///
+ /// Gets or sets the database name.
+ ///
+ ///
+ /// For this application: "leadprocessor" or similar.
+ ///
+ public required string Database { get; set; }
+
+ ///
+ /// Gets or sets the database user for authentication.
+ ///
+ public required string User { get; set; }
+
+ ///
+ /// Gets or sets the database password for authentication.
+ ///
+ ///
+ /// In production, should be loaded from AWS Secrets Manager, not hardcoded or in appsettings.
+ ///
+ public required string Password { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of retry attempts for transient database failures.
+ ///
+ ///
+ /// Default is 3 retries for transient failures (connection timeouts, deadlocks, etc.).
+ /// Set to 0 to disable retries.
+ ///
+ public int MaxRetryAttempts { get; set; } = 3;
+
+ ///
+ /// Gets or sets the maximum delay between retry attempts in seconds.
+ ///
+ ///
+ /// Default is 10 seconds. Actual delay is randomized between 0 and this value.
+ ///
+ public int MaxRetryDelaySeconds { get; set; } = 10;
+
+ ///
+ /// Gets or sets the command timeout in seconds for database operations.
+ ///
+ ///
+ /// Default is 30 seconds. Increase for long-running queries or bulk operations.
+ ///
+ public int CommandTimeoutSeconds { get; set; } = 30;
+
+ ///
+ /// Gets or sets a value indicating whether to enable detailed error logging.
+ ///
+ ///
+ /// When enabled, provides more detailed diagnostics about database operations.
+ /// Should be disabled in production for security and performance.
+ ///
+ public bool EnableDetailedErrors { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether to enable EF Core query logging.
+ ///
+ ///
+ /// When enabled, logs all generated SQL queries. Should only be used for debugging.
+ /// Can impact performance and expose sensitive data in logs.
+ ///
+ public bool EnableQueryLogging { get; set; } = false;
+
+ ///
+ /// Validates the configuration settings.
+ ///
+ /// A list of validation errors, empty if valid.
+ ///
+ /// Called during startup to ensure all required settings are present and valid.
+ ///
+ public IEnumerable Validate()
+ {
+ if (string.IsNullOrWhiteSpace(ConnectionString))
+ yield return "ConnectionString is required";
+
+ if (string.IsNullOrWhiteSpace(Server))
+ yield return "Server is required";
+
+ if (Port <= 0 || Port > 65535)
+ yield return $"Port must be between 1 and 65535, got {Port}";
+
+ if (string.IsNullOrWhiteSpace(Database))
+ yield return "Database is required";
+
+ if (string.IsNullOrWhiteSpace(User))
+ yield return "User is required";
+
+ if (string.IsNullOrWhiteSpace(Password))
+ yield return "Password is required";
+
+ if (MaxRetryAttempts < 0)
+ yield return $"MaxRetryAttempts must be >= 0, got {MaxRetryAttempts}";
+
+ if (MaxRetryDelaySeconds <= 0)
+ yield return $"MaxRetryDelaySeconds must be > 0, got {MaxRetryDelaySeconds}";
+
+ if (CommandTimeoutSeconds <= 0)
+ yield return $"CommandTimeoutSeconds must be > 0, got {CommandTimeoutSeconds}";
+ }
+}
diff --git a/src/LeadProcessor.Infrastructure/LeadProcessor.Infrastructure.csproj b/src/LeadProcessor.Infrastructure/LeadProcessor.Infrastructure.csproj
index 88902d0..5adc184 100644
--- a/src/LeadProcessor.Infrastructure/LeadProcessor.Infrastructure.csproj
+++ b/src/LeadProcessor.Infrastructure/LeadProcessor.Infrastructure.csproj
@@ -7,8 +7,10 @@
-
-
+
+
+
+
diff --git a/src/LeadProcessor.Infrastructure/Models/DatabaseCredentials.cs b/src/LeadProcessor.Infrastructure/Models/DatabaseCredentials.cs
new file mode 100644
index 0000000..33e4960
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Models/DatabaseCredentials.cs
@@ -0,0 +1,56 @@
+namespace LeadProcessor.Infrastructure.Models;
+
+///
+/// Represents database credentials retrieved from AWS Secrets Manager.
+///
+///
+/// This immutable record type ensures credentials are not modified after retrieval.
+/// Used to construct database connection strings from AWS Secrets Manager secrets.
+///
+public sealed record DatabaseCredentials
+{
+ ///
+ /// Gets the database server hostname or endpoint.
+ ///
+ ///
+ /// For RDS: typically in format "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com"
+ /// For local development: "localhost"
+ ///
+ public required string Host { get; init; }
+
+ ///
+ /// Gets the database server port.
+ ///
+ ///
+ /// Default MySQL port is 3306, PostgreSQL is 5432.
+ ///
+ public required int Port { get; init; }
+
+ ///
+ /// Gets the database name.
+ ///
+ public required string Database { get; init; }
+
+ ///
+ /// Gets the database username for authentication.
+ ///
+ public required string Username { get; init; }
+
+ ///
+ /// Gets the database password for authentication.
+ ///
+ ///
+ /// This value should be kept secure and not logged.
+ /// In production, retrieved from AWS Secrets Manager with automatic rotation support.
+ ///
+ public required string Password { get; init; }
+
+ ///
+ /// Gets the database engine type (e.g., "mysql", "postgres").
+ ///
+ ///
+ /// Optional field that can be used for engine-specific connection string formatting.
+ ///
+ public string? Engine { get; init; }
+}
+
diff --git a/src/LeadProcessor.Infrastructure/Persistence/LeadProcessorDbContext.cs b/src/LeadProcessor.Infrastructure/Persistence/LeadProcessorDbContext.cs
new file mode 100644
index 0000000..10de1a1
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Persistence/LeadProcessorDbContext.cs
@@ -0,0 +1,173 @@
+namespace LeadProcessor.Infrastructure.Persistence;
+
+using LeadProcessor.Domain.Entities;
+using LeadProcessor.Domain.Services;
+using Microsoft.EntityFrameworkCore;
+
+///
+/// Entity Framework Core database context for the Lead Processor application.
+///
+///
+/// This context manages the Lead entity and ensures UTC timestamp handling for all DateTimeOffset properties.
+/// Automatically updates the UpdatedAt timestamp for modified entities during save operations.
+///
+/// The options to configure the context.
+/// The provider for consistent UTC time handling.
+public class LeadProcessorDbContext(
+ DbContextOptions options,
+ IDateTimeProvider dateTimeProvider) : DbContext(options)
+{
+ ///
+ /// Gets or sets the DbSet for Lead entities.
+ ///
+ public DbSet Leads => Set();
+
+ ///
+ /// Configures the model using Fluent API.
+ ///
+ /// The model builder used to configure the entities.
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity(entity =>
+ {
+ // Table configuration
+ entity.ToTable("Leads");
+ entity.HasKey(e => e.Id);
+
+ // Primary key configuration
+ entity.Property(e => e.Id)
+ .ValueGeneratedOnAdd();
+
+ // Required string properties with max lengths
+ entity.Property(e => e.TenantId)
+ .IsRequired()
+ .HasMaxLength(100);
+
+ entity.Property(e => e.CorrelationId)
+ .IsRequired()
+ .HasMaxLength(100);
+
+ entity.Property(e => e.Email)
+ .IsRequired()
+ .HasMaxLength(320); // RFC 5321 max email length
+
+ entity.Property(e => e.Source)
+ .IsRequired()
+ .HasMaxLength(100);
+
+ // Optional string properties with max lengths
+ entity.Property(e => e.FirstName)
+ .HasMaxLength(100);
+
+ entity.Property(e => e.LastName)
+ .HasMaxLength(100);
+
+ entity.Property(e => e.Phone)
+ .HasMaxLength(50);
+
+ entity.Property(e => e.Company)
+ .HasMaxLength(200);
+
+ // JSON column for metadata
+ entity.Property(e => e.Metadata)
+ .HasColumnType("json");
+
+ // UTC DateTimeOffset configuration
+ // Store as UTC datetime and reconstruct with zero offset on retrieval
+ entity.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasConversion(
+ v => v.UtcDateTime,
+ v => new DateTimeOffset(v, TimeSpan.Zero));
+
+ entity.Property(e => e.UpdatedAt)
+ .IsRequired()
+ .HasConversion(
+ v => v.UtcDateTime,
+ v => new DateTimeOffset(v, TimeSpan.Zero));
+
+ // Indexes for query performance
+ entity.HasIndex(e => e.TenantId)
+ .HasDatabaseName("IX_Leads_TenantId");
+
+ entity.HasIndex(e => e.CorrelationId)
+ .IsUnique()
+ .HasDatabaseName("IX_Leads_CorrelationId");
+
+ entity.HasIndex(e => e.Email)
+ .HasDatabaseName("IX_Leads_Email");
+
+ entity.HasIndex(e => e.CreatedAt)
+ .HasDatabaseName("IX_Leads_CreatedAt");
+
+ // Composite index for tenant-based queries
+ entity.HasIndex(e => new { e.TenantId, e.CreatedAt })
+ .HasDatabaseName("IX_Leads_TenantId_CreatedAt");
+ });
+ }
+
+ ///
+ /// Saves all changes made in this context to the database.
+ /// Automatically updates the UpdatedAt timestamp for modified Lead entities.
+ ///
+ /// A cancellation token to observe while waiting for the task to complete.
+ /// A task that represents the asynchronous save operation. The task result contains the number of state entries written to the database.
+ ///
+ /// This method intercepts modified Lead entities and creates new instances with updated timestamps
+ /// to maintain immutability of record types while ensuring audit trail accuracy.
+ ///
+ public override async Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ UpdateModifiedLeadTimestamps();
+ return await base.SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ /// Saves all changes made in this context to the database synchronously.
+ ///
+ /// Indicates whether all changes should be accepted if the save operation succeeds.
+ /// The number of state entries written to the database.
+ ///
+ /// This synchronous method is provided for compatibility but async methods should be preferred.
+ ///
+ public override int SaveChanges(bool acceptAllChangesOnSuccess)
+ {
+ UpdateModifiedLeadTimestamps();
+ return base.SaveChanges(acceptAllChangesOnSuccess);
+ }
+
+ ///
+ /// Updates the UpdatedAt timestamp for all modified Lead entities in the change tracker.
+ ///
+ ///
+ /// This method caches the current UTC time once to ensure all modified entities
+ /// in the same transaction receive the exact same timestamp, maintaining transactional consistency.
+ /// The timestamp update is required to maintain immutability of record types while ensuring audit trail accuracy.
+ ///
+ private void UpdateModifiedLeadTimestamps()
+ {
+ var modifiedLeadEntries = ChangeTracker.Entries()
+ .Where(e => e.State == EntityState.Modified)
+ .ToList();
+
+ if (modifiedLeadEntries.Count == 0)
+ {
+ return;
+ }
+
+ // Cache the timestamp once to ensure all entities get the same value
+ // This maintains transactional consistency
+ var updateTimestamp = dateTimeProvider.UtcNow;
+
+ foreach (var entry in modifiedLeadEntries)
+ {
+ // Create a new Lead instance with the updated timestamp
+ // This is required because Lead is a record type (immutable)
+ var updatedLead = entry.Entity with { UpdatedAt = updateTimestamp };
+ entry.CurrentValues.SetValues(updatedLead);
+ }
+ }
+}
+
diff --git a/src/LeadProcessor.Infrastructure/Repositories/LeadRepository.cs b/src/LeadProcessor.Infrastructure/Repositories/LeadRepository.cs
new file mode 100644
index 0000000..635c0e7
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Repositories/LeadRepository.cs
@@ -0,0 +1,109 @@
+using LeadProcessor.Domain.Entities;
+using LeadProcessor.Domain.Repositories;
+using LeadProcessor.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace LeadProcessor.Infrastructure.Repositories;
+
+///
+/// EF Core implementation of for managing lead persistence operations.
+///
+///
+/// This repository provides data access operations for the Lead entity using Entity Framework Core.
+/// All operations are async and support cancellation tokens for graceful shutdown.
+/// Thread-safe when used with proper DbContext lifecycle management (scoped per request).
+///
+/// The database context for lead operations.
+public sealed class LeadRepository(LeadProcessorDbContext context) : ILeadRepository
+{
+ private readonly LeadProcessorDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
+
+ ///
+ /// Saves a lead to the data store asynchronously.
+ ///
+ /// The lead entity to save.
+ /// Cancellation token to cancel the operation.
+ /// The saved lead entity with updated fields (e.g., Id, timestamps).
+ /// Thrown when lead is null.
+ /// Thrown when a database update error occurs.
+ ///
+ /// This method handles both insert and update operations:
+ /// - For new leads (Id == 0), performs an INSERT
+ /// - For existing leads (Id > 0), performs an UPDATE
+ /// The UpdatedAt timestamp is automatically updated by the DbContext on save.
+ /// For updates, detaches any tracked entity to prevent tracking conflicts.
+ ///
+ public async Task SaveLeadAsync(Lead lead, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(lead);
+
+ // Determine if this is a new entity or an existing one
+ if (lead.Id == 0)
+ {
+ // New entity - add to context for insert
+ await _context.Leads.AddAsync(lead, cancellationToken);
+ }
+ else
+ {
+ // Existing entity - check if it's already tracked
+ var trackedEntity = _context.Leads.Local.FirstOrDefault(l => l.Id == lead.Id);
+ if (trackedEntity != null)
+ {
+ // Detach the tracked entity to avoid conflicts
+ _context.Entry(trackedEntity).State = EntityState.Detached;
+ }
+
+ // Attach and mark as modified for update
+ _context.Leads.Update(lead);
+ }
+
+ await _context.SaveChangesAsync(cancellationToken);
+ return lead;
+ }
+
+ ///
+ /// Checks if a lead with the specified correlation ID already exists.
+ /// This method supports idempotency by allowing duplicate message detection.
+ ///
+ /// The correlation ID to check for.
+ /// Cancellation token to cancel the operation.
+ /// True if a lead with the correlation ID exists, otherwise false.
+ /// Thrown when correlationId is null or whitespace.
+ ///
+ /// This method uses an optimized query that only checks for existence without loading the entity.
+ /// It leverages the indexed CorrelationId column for fast lookups.
+ ///
+ public async Task ExistsByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(correlationId))
+ {
+ throw new ArgumentException("Correlation ID cannot be null or whitespace.", nameof(correlationId));
+ }
+
+ return await _context.Leads
+ .AnyAsync(l => l.CorrelationId == correlationId, cancellationToken);
+ }
+
+ ///
+ /// Retrieves a lead by its correlation ID asynchronously.
+ ///
+ /// The correlation ID to search for.
+ /// Cancellation token to cancel the operation.
+ /// The lead entity if found, otherwise null.
+ /// Thrown when correlationId is null or whitespace.
+ ///
+ /// This method leverages the indexed CorrelationId column for efficient lookups.
+ /// Returns null if no lead is found with the specified correlation ID.
+ ///
+ public async Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(correlationId))
+ {
+ throw new ArgumentException("Correlation ID cannot be null or whitespace.", nameof(correlationId));
+ }
+
+ return await _context.Leads
+ .FirstOrDefaultAsync(l => l.CorrelationId == correlationId, cancellationToken);
+ }
+}
+
diff --git a/src/LeadProcessor.Infrastructure/Services/DbConnectionStringProvider.cs b/src/LeadProcessor.Infrastructure/Services/DbConnectionStringProvider.cs
new file mode 100644
index 0000000..a1c34ce
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Services/DbConnectionStringProvider.cs
@@ -0,0 +1,66 @@
+namespace LeadProcessor.Infrastructure.Services;
+
+///
+/// Thread-safe implementation of that caches the database connection string.
+///
+///
+/// This class is designed to be registered as a singleton in the dependency injection container.
+/// The connection string is initialized during Lambda cold start and cached for the lifetime
+/// of the Lambda instance, avoiding repeated calls to AWS Secrets Manager.
+/// All methods are thread-safe and can be called concurrently from multiple threads.
+///
+public sealed class DbConnectionStringProvider : IDbConnectionStringProvider
+{
+ private string? _connectionString;
+ private readonly object _lock = new();
+
+ ///
+ /// Initializes the connection string provider with the specified connection string.
+ ///
+ /// The database connection string to store.
+ /// Thrown when connectionString is null.
+ /// Thrown when connectionString is empty or whitespace.
+ ///
+ /// This method is thread-safe and can be called multiple times. Each call will overwrite
+ /// the previously stored connection string. In a typical Lambda scenario, this is called
+ /// once during cold start initialization.
+ ///
+ public void Initialize(string connectionString)
+ {
+ if (connectionString == null)
+ {
+ throw new ArgumentNullException(nameof(connectionString));
+ }
+
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new ArgumentException("Connection string cannot be empty or whitespace.", nameof(connectionString));
+ }
+
+ lock (_lock)
+ {
+ _connectionString = connectionString;
+ }
+ }
+
+ ///
+ /// Retrieves the database connection string.
+ ///
+ /// The database connection string that was previously initialized.
+ ///
+ /// Thrown when the connection string has not been initialized via .
+ ///
+ ///
+ /// This method is thread-safe and can be called concurrently from multiple threads.
+ /// It will always return the most recently initialized connection string.
+ ///
+ public string GetConnectionString()
+ {
+ lock (_lock)
+ {
+ return _connectionString ?? throw new InvalidOperationException(
+ "Connection string not initialized. Call Initialize() before GetConnectionString().");
+ }
+ }
+}
+
diff --git a/src/LeadProcessor.Infrastructure/Services/IDbConnectionStringProvider.cs b/src/LeadProcessor.Infrastructure/Services/IDbConnectionStringProvider.cs
new file mode 100644
index 0000000..fe1bf49
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Services/IDbConnectionStringProvider.cs
@@ -0,0 +1,40 @@
+namespace LeadProcessor.Infrastructure.Services;
+
+///
+/// Provides access to the database connection string.
+///
+///
+/// This interface enables thread-safe access to the database connection string
+/// which is initialized during Lambda cold start. The connection string is typically
+/// retrieved from AWS Secrets Manager and cached for the lifetime of the Lambda instance.
+/// Implementations must be thread-safe.
+///
+public interface IDbConnectionStringProvider
+{
+ ///
+ /// Initializes the connection string provider with the specified connection string.
+ ///
+ /// The database connection string to store.
+ /// Thrown when connectionString is null.
+ /// Thrown when connectionString is empty or whitespace.
+ ///
+ /// This method should be called once during Lambda initialization (cold start)
+ /// before any database operations are performed. Subsequent calls will overwrite
+ /// the existing connection string.
+ /// Thread-safe: Can be called from multiple threads simultaneously.
+ ///
+ void Initialize(string connectionString);
+
+ ///
+ /// Retrieves the database connection string.
+ ///
+ /// The database connection string.
+ /// Thrown when the connection string has not been initialized.
+ ///
+ /// This method is called by the DbContext factory to obtain the connection string
+ /// for creating database connections. It must be called after Initialize().
+ /// Thread-safe: Can be called from multiple threads simultaneously.
+ ///
+ string GetConnectionString();
+}
+
diff --git a/src/LeadProcessor.Infrastructure/Services/ISecretsManagerService.cs b/src/LeadProcessor.Infrastructure/Services/ISecretsManagerService.cs
new file mode 100644
index 0000000..f75c4a7
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Services/ISecretsManagerService.cs
@@ -0,0 +1,61 @@
+namespace LeadProcessor.Infrastructure.Services;
+
+using LeadProcessor.Infrastructure.Models;
+
+///
+/// Provides access to secrets stored in AWS Secrets Manager.
+///
+///
+/// This interface wraps the Cloudvelous.Aws.SecretsManager SDK, providing:
+/// - Automatic caching (60 minutes default, configurable)
+/// - Built-in retry policies with exponential backoff
+/// - Type-safe secret deserialization
+/// - Structured logging integration
+/// All implementations are thread-safe and designed for Lambda cold-start optimization.
+///
+public interface ISecretsManagerService
+{
+ ///
+ /// Retrieves database credentials from AWS Secrets Manager.
+ ///
+ /// The name or ARN of the secret containing database credentials.
+ /// Cancellation token to cancel the operation.
+ /// A task that represents the asynchronous operation. The task result contains the database credentials.
+ /// Thrown when secretName is null or whitespace.
+ /// Thrown when the secret is not found.
+ /// Thrown when the secret value cannot be deserialized.
+ ///
+ /// The secret value must be a JSON string with the following structure:
+ ///
+ /// {
+ /// "host": "database-endpoint.region.rds.amazonaws.com",
+ /// "port": 3306,
+ /// "database": "dbname",
+ /// "username": "admin",
+ /// "password": "secret",
+ /// "engine": "mysql"
+ /// }
+ ///
+ /// Cloudvelous SDK handles caching automatically (default 60 minutes).
+ /// Thread-safe: Can be called from multiple threads simultaneously.
+ ///
+ Task GetDatabaseCredentialsAsync(string secretName, CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves a typed secret value from AWS Secrets Manager.
+ ///
+ /// The type to deserialize the secret value into.
+ /// The name or ARN of the secret to retrieve.
+ /// Cancellation token to cancel the operation.
+ /// A task that represents the asynchronous operation. The task result contains the deserialized secret value.
+ /// Thrown when secretName is null or whitespace.
+ /// Thrown when the secret is not found.
+ /// Thrown when the secret value cannot be deserialized.
+ ///
+ /// This method uses Cloudvelous SDK's built-in type-safe deserialization.
+ /// Cloudvelous SDK handles caching automatically (default 60 minutes).
+ /// Thread-safe: Can be called from multiple threads simultaneously.
+ ///
+ Task GetSecretAsync(string secretName, CancellationToken cancellationToken = default) where T : class;
+}
+
diff --git a/src/LeadProcessor.Infrastructure/Services/SecretsManagerService.cs b/src/LeadProcessor.Infrastructure/Services/SecretsManagerService.cs
new file mode 100644
index 0000000..3457aa8
--- /dev/null
+++ b/src/LeadProcessor.Infrastructure/Services/SecretsManagerService.cs
@@ -0,0 +1,104 @@
+namespace LeadProcessor.Infrastructure.Services;
+
+using Cloudvelous.Aws.SecretsManager;
+using LeadProcessor.Infrastructure.Models;
+using Microsoft.Extensions.Logging;
+
+///
+/// AWS Secrets Manager service using Cloudvelous SDK with built-in caching and retry policies.
+///
+///
+/// This service wraps the Cloudvelous.Aws.SecretsManager SDK, which provides:
+/// - Automatic caching with configurable TTL (default 60 minutes)
+/// - Built-in retry policies using Polly (3 retries with exponential backoff)
+/// - Type-safe deserialization with System.Text.Json
+/// - Thread-safe operations using concurrent collections
+/// All caching, retry, and resilience logic is handled by the Cloudvelous SDK.
+///
+/// The Cloudvelous Secrets Manager client.
+/// The logger for diagnostic information.
+public sealed class SecretsManagerService(
+ ISecretsManagerClient secretsManagerClient,
+ ILogger logger) : ISecretsManagerService
+{
+ private readonly ISecretsManagerClient _secretsManagerClient = secretsManagerClient ?? throw new ArgumentNullException(nameof(secretsManagerClient));
+ private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ ///
+ /// Retrieves database credentials from AWS Secrets Manager.
+ ///
+ /// The name or ARN of the secret containing database credentials.
+ /// Cancellation token to cancel the operation.
+ /// A task that represents the asynchronous operation. The task result contains the database credentials.
+ /// Thrown when secretName is null or whitespace.
+ ///
+ /// Cloudvelous SDK automatically:
+ /// - Caches the secret for 60 minutes (configurable)
+ /// - Retries failed requests with exponential backoff
+ /// - Deserializes JSON to strongly-typed objects
+ /// - Handles thread-safety with concurrent collections
+ ///
+ public async Task GetDatabaseCredentialsAsync(string secretName, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(secretName))
+ {
+ throw new ArgumentException("Secret name cannot be null or whitespace.", nameof(secretName));
+ }
+
+ _logger.LogDebug("Retrieving database credentials for secret: {SecretName}", secretName);
+
+ try
+ {
+ // Cloudvelous SDK handles caching, retries, and deserialization automatically
+ var credentials = await _secretsManagerClient.GetSecretValueAsync(secretName);
+
+ _logger.LogInformation("Successfully retrieved database credentials for secret: {SecretName}", secretName);
+ return credentials!;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to retrieve database credentials for secret: {SecretName}", secretName);
+ throw;
+ }
+ }
+
+ ///
+ /// Retrieves a typed secret value from AWS Secrets Manager.
+ ///
+ /// The type to deserialize the secret value into.
+ /// The name or ARN of the secret to retrieve.
+ /// Cancellation token to cancel the operation.
+ /// A task that represents the asynchronous operation. The task result contains the deserialized secret value.
+ /// Thrown when secretName is null or whitespace.
+ ///
+ /// Cloudvelous SDK automatically:
+ /// - Caches the secret for 60 minutes (configurable)
+ /// - Retries failed requests with exponential backoff
+ /// - Deserializes JSON to strongly-typed objects
+ /// - Handles thread-safety with concurrent collections
+ ///
+ public async Task GetSecretAsync(string secretName, CancellationToken cancellationToken = default) where T : class
+ {
+ if (string.IsNullOrWhiteSpace(secretName))
+ {
+ throw new ArgumentException("Secret name cannot be null or whitespace.", nameof(secretName));
+ }
+
+ _logger.LogDebug("Retrieving secret: {SecretName}", secretName);
+
+ try
+ {
+ // Cloudvelous SDK handles caching, retries, and deserialization automatically
+ var secret = await _secretsManagerClient.GetSecretValueAsync(secretName);
+
+ _logger.LogInformation("Successfully retrieved secret: {SecretName}", secretName);
+ return secret!;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to retrieve secret: {SecretName}", secretName);
+ throw;
+ }
+ }
+}
+
diff --git a/src/LeadProcessor.Infrastructure/Services/SystemDateTimeProvider.cs b/src/LeadProcessor.Infrastructure/Services/SystemDateTimeProvider.cs
index 96725eb..be8dca5 100644
--- a/src/LeadProcessor.Infrastructure/Services/SystemDateTimeProvider.cs
+++ b/src/LeadProcessor.Infrastructure/Services/SystemDateTimeProvider.cs
@@ -1,15 +1,22 @@
-using LeadProcessor.Domain.Services;
-
namespace LeadProcessor.Infrastructure.Services;
+using LeadProcessor.Domain.Services;
+
///
-/// System implementation of IDateTimeProvider that returns the actual system time.
+/// Production implementation of that returns the current system UTC time.
///
-public class SystemDateTimeProvider : IDateTimeProvider
+///
+/// This implementation is thread-safe and stateless, making it suitable for singleton registration in DI.
+/// Always returns the current UTC time via .
+///
+public sealed class SystemDateTimeProvider : IDateTimeProvider
{
///
/// Gets the current UTC date and time from the system clock.
///
+ ///
+ /// This property is thread-safe and can be called concurrently from multiple threads.
+ /// Each access returns the current UTC time at the moment of the call.
+ ///
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
-
diff --git a/tests/LeadProcessor.TestHelpers/LeadProcessor.TestHelpers.csproj b/tests/LeadProcessor.TestHelpers/LeadProcessor.TestHelpers.csproj
index fa71b7a..597c60d 100644
--- a/tests/LeadProcessor.TestHelpers/LeadProcessor.TestHelpers.csproj
+++ b/tests/LeadProcessor.TestHelpers/LeadProcessor.TestHelpers.csproj
@@ -6,4 +6,8 @@
enable
+
+
+
+
diff --git a/tests/LeadProcessor.TestHelpers/Services/FixedDateTimeProvider.cs b/tests/LeadProcessor.TestHelpers/Services/FixedDateTimeProvider.cs
new file mode 100644
index 0000000..c1eea65
--- /dev/null
+++ b/tests/LeadProcessor.TestHelpers/Services/FixedDateTimeProvider.cs
@@ -0,0 +1,29 @@
+namespace LeadProcessor.TestHelpers.Services;
+
+using LeadProcessor.Domain.Services;
+
+///
+/// Test implementation of that returns a fixed, predetermined datetime.
+///
+///
+/// This implementation is ideal for unit and integration tests where deterministic, repeatable
+/// time values are required. The fixed datetime is set at construction and never changes,
+/// ensuring consistent test results regardless of when the test runs.
+/// Thread-safe and immutable after construction.
+///
+/// The fixed UTC datetime to return for all calls to .
+public sealed class FixedDateTimeProvider(DateTimeOffset fixedDateTime) : IDateTimeProvider
+{
+ private readonly DateTimeOffset _fixedDateTime = fixedDateTime;
+
+ ///
+ /// Gets the fixed UTC date and time that was set at construction.
+ ///
+ ///
+ /// This property always returns the same datetime value that was provided to the constructor,
+ /// enabling deterministic testing of time-dependent logic.
+ /// Thread-safe - can be called concurrently from multiple threads.
+ ///
+ public DateTimeOffset UtcNow => _fixedDateTime;
+}
+
diff --git a/tests/LeadProcessor.UnitTests/Infrastructure/Configuration/AwsSettingsTests.cs b/tests/LeadProcessor.UnitTests/Infrastructure/Configuration/AwsSettingsTests.cs
new file mode 100644
index 0000000..7b8a8d2
--- /dev/null
+++ b/tests/LeadProcessor.UnitTests/Infrastructure/Configuration/AwsSettingsTests.cs
@@ -0,0 +1,327 @@
+using LeadProcessor.Infrastructure.Configuration;
+
+namespace LeadProcessor.UnitTests.Infrastructure.Configuration;
+
+///
+/// Unit tests for .
+///
+public sealed class AwsSettingsTests
+{
+ #region Valid Settings Tests
+
+ [Fact]
+ public void ValidSettings_ReturnsNoValidationErrors()
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void ValidSettings_WithIamAuthentication_ReturnsNoValidationErrors()
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "eu-west-1",
+ SqsQueueUrl = "https://sqs.eu-west-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = "https://sqs.eu-west-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.eu-west-1.rds.amazonaws.com",
+ UseIamDatabaseAuthentication = true,
+ IamTokenLifetimeSeconds = 600
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors);
+ }
+
+ [Theory]
+ [InlineData("us-east-1")]
+ [InlineData("eu-west-1")]
+ [InlineData("ap-southeast-1")]
+ [InlineData("us-west-2")]
+ public void ValidSettings_WithDifferentRegions_ReturnsNoValidationErrors(string region)
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = region,
+ SqsQueueUrl = $"https://sqs.{region}.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = $"https://sqs.{region}.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = $"leadprocessor-db.c9akciq32.{region}.rds.amazonaws.com"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors);
+ }
+
+ #endregion
+
+ #region Required Field Validation Tests
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void MissingRegion_ReturnsValidationError(string invalidValue)
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = invalidValue,
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains("Region is required", errors);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void MissingSqsQueueUrl_ReturnsValidationError(string invalidValue)
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = invalidValue,
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains("SqsQueueUrl is required", errors);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void MissingSqsDlqUrl_ReturnsValidationError(string invalidValue)
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = invalidValue,
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains("SqsDlqUrl is required", errors);
+ }
+
+ #endregion
+
+ #region Numeric Field Validation Tests
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(-100)]
+ public void NegativeMaxRetryAttempts_ReturnsValidationError(int negativeRetries)
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com",
+ MaxRetryAttempts = negativeRetries
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains(
+ errors,
+ e => e.Contains("MaxRetryAttempts must be >= 0"));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public void InvalidIamTokenLifetimeSeconds_ReturnsValidationError(int invalidLifetime)
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com",
+ IamTokenLifetimeSeconds = invalidLifetime
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains(
+ errors,
+ e => e.Contains("IamTokenLifetimeSeconds must be > 0"));
+ }
+
+ #endregion
+
+ #region URL Format Validation Tests
+
+ [Theory]
+ [InlineData("http://sqs.us-east-1.amazonaws.com/123456789012/lead-events")]
+ [InlineData("sqs.us-east-1.amazonaws.com/123456789012/lead-events")]
+ [InlineData("https://dynamodb.us-east-1.amazonaws.com/123456789012/lead-events")]
+ public void InvalidSqsQueueUrl_ReturnsValidationError(string invalidUrl)
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = invalidUrl,
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains(
+ errors,
+ e => e.Contains("SqsQueueUrl should be in format"));
+ }
+
+ [Theory]
+ [InlineData("leadprocessor-db.us-east-1.ec2.amazonaws.com")]
+ [InlineData("leadprocessor-db.us-east-1.redshift.amazonaws.com")]
+ [InlineData("localhost")]
+ public void InvalidRdsEndpoint_ReturnsValidationError(string invalidEndpoint)
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = invalidEndpoint
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains(
+ errors,
+ e => e.Contains("RdsEndpoint should be in format"));
+ }
+
+ #endregion
+
+ #region Default Values Tests
+
+ [Fact]
+ public void NewInstance_HasCorrectDefaultValues()
+ {
+ // Arrange & Act
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/dummy",
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/dummy",
+ SecretsManagerSecretName = "dummy",
+ RdsEndpoint = "dummy.us-east-1.rds.amazonaws.com"
+ };
+
+ // Assert
+ Assert.Equal(3, settings.MaxRetryAttempts);
+ Assert.False(settings.UseIamDatabaseAuthentication);
+ Assert.Equal(900, settings.IamTokenLifetimeSeconds);
+ Assert.True(settings.ValidateCredentialsAtStartup);
+ }
+
+ #endregion
+
+ #region Edge Cases Tests
+
+ [Fact]
+ public void ValidSettings_WithZeroRetries_ReturnsNoValidationError()
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com",
+ MaxRetryAttempts = 0
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors.Where(e => e.Contains("MaxRetryAttempts")));
+ }
+
+ [Fact]
+ public void ValidSettings_WithMinimalIamTokenLifetime_ReturnsNoValidationError()
+ {
+ // Arrange
+ var settings = new AwsSettings
+ {
+ Region = "us-east-1",
+ SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events",
+ SqsDlqUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/lead-events-dlq",
+ SecretsManagerSecretName = "leadprocessor/rds/credentials",
+ RdsEndpoint = "leadprocessor-db.c9akciq32.us-east-1.rds.amazonaws.com",
+ IamTokenLifetimeSeconds = 1
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors.Where(e => e.Contains("IamTokenLifetimeSeconds")));
+ }
+
+ #endregion
+}
diff --git a/tests/LeadProcessor.UnitTests/Infrastructure/Configuration/DatabaseSettingsTests.cs b/tests/LeadProcessor.UnitTests/Infrastructure/Configuration/DatabaseSettingsTests.cs
new file mode 100644
index 0000000..dd29a18
--- /dev/null
+++ b/tests/LeadProcessor.UnitTests/Infrastructure/Configuration/DatabaseSettingsTests.cs
@@ -0,0 +1,339 @@
+using LeadProcessor.Infrastructure.Configuration;
+
+namespace LeadProcessor.UnitTests.Infrastructure.Configuration;
+
+///
+/// Unit tests for .
+///
+public sealed class DatabaseSettingsTests
+{
+ #region Valid Settings Tests
+
+ [Fact]
+ public void ValidSettings_ReturnsNoValidationErrors()
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "Server=localhost;Port=3306;Database=leadprocessor;User=root;Password=password",
+ Server = "localhost",
+ Port = 3306,
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void ValidSettings_WithMaxValues_ReturnsNoValidationErrors()
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "Server=db.example.com;Port=65535;Database=leadprocessor;User=root;Password=password",
+ Server = "db.example.com",
+ Port = 65535,
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password",
+ MaxRetryAttempts = 10,
+ MaxRetryDelaySeconds = 300,
+ CommandTimeoutSeconds = 600,
+ EnableDetailedErrors = true,
+ EnableQueryLogging = true
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors);
+ }
+
+ #endregion
+
+ #region Required Field Validation Tests
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData(" ")]
+ public void MissingConnectionString_ReturnsValidationError(string invalidValue)
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = invalidValue,
+ Server = "localhost",
+ Port = 3306,
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains("ConnectionString is required", errors);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void MissingServer_ReturnsValidationError(string invalidValue)
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = invalidValue,
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains("Server is required", errors);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void MissingDatabase_ReturnsValidationError(string invalidValue)
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "localhost",
+ Database = invalidValue,
+ User = "root",
+ Password = "password"
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains("Database is required", errors);
+ }
+
+ #endregion
+
+ #region Port Validation Tests
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(65536)]
+ [InlineData(100000)]
+ public void InvalidPort_ReturnsValidationError(int invalidPort)
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "localhost",
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password",
+ Port = invalidPort
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains(
+ errors,
+ e => e.Contains("Port must be between 1 and 65535"));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(3306)]
+ [InlineData(5432)]
+ [InlineData(65535)]
+ public void ValidPort_ReturnsNoError(int validPort)
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "localhost",
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password",
+ Port = validPort
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors.Where(e => e.Contains("Port")));
+ }
+
+ #endregion
+
+ #region Retry Settings Validation Tests
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(-100)]
+ public void NegativeMaxRetryAttempts_ReturnsValidationError(int negativeRetries)
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "localhost",
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password",
+ MaxRetryAttempts = negativeRetries
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains(
+ errors,
+ e => e.Contains("MaxRetryAttempts must be >= 0"));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public void InvalidMaxRetryDelaySeconds_ReturnsValidationError(int invalidDelay)
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "localhost",
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password",
+ MaxRetryDelaySeconds = invalidDelay
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains(
+ errors,
+ e => e.Contains("MaxRetryDelaySeconds must be > 0"));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-10)]
+ public void InvalidCommandTimeoutSeconds_ReturnsValidationError(int invalidTimeout)
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "localhost",
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password",
+ CommandTimeoutSeconds = invalidTimeout
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Contains(
+ errors,
+ e => e.Contains("CommandTimeoutSeconds must be > 0"));
+ }
+
+ #endregion
+
+ #region Default Values Tests
+
+ [Fact]
+ public void NewInstance_HasCorrectDefaultValues()
+ {
+ // Arrange & Act
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "dummy",
+ Database = "dummy",
+ User = "dummy",
+ Password = "dummy"
+ };
+
+ // Assert
+ Assert.Equal(3306, settings.Port);
+ Assert.Equal(3, settings.MaxRetryAttempts);
+ Assert.Equal(10, settings.MaxRetryDelaySeconds);
+ Assert.Equal(30, settings.CommandTimeoutSeconds);
+ Assert.False(settings.EnableDetailedErrors);
+ Assert.False(settings.EnableQueryLogging);
+ }
+
+ #endregion
+
+ #region Edge Cases Tests
+
+ [Fact]
+ public void ValidSettings_WithZeroRetries_ReturnsNoValidationError()
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "localhost",
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password",
+ MaxRetryAttempts = 0
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors.Where(e => e.Contains("MaxRetryAttempts")));
+ }
+
+ [Fact]
+ public void ValidSettings_WithMinimumPort_ReturnsNoValidationError()
+ {
+ // Arrange
+ var settings = new DatabaseSettings
+ {
+ ConnectionString = "dummy",
+ Server = "localhost",
+ Database = "leadprocessor",
+ User = "root",
+ Password = "password",
+ Port = 1
+ };
+
+ // Act
+ var errors = settings.Validate().ToList();
+
+ // Assert
+ Assert.Empty(errors.Where(e => e.Contains("Port")));
+ }
+
+ #endregion
+}
diff --git a/tests/LeadProcessor.UnitTests/Infrastructure/Persistence/LeadProcessorDbContextTests.cs b/tests/LeadProcessor.UnitTests/Infrastructure/Persistence/LeadProcessorDbContextTests.cs
new file mode 100644
index 0000000..c68a1a9
--- /dev/null
+++ b/tests/LeadProcessor.UnitTests/Infrastructure/Persistence/LeadProcessorDbContextTests.cs
@@ -0,0 +1,353 @@
+using LeadProcessor.Domain.Entities;
+using LeadProcessor.Infrastructure.Persistence;
+using LeadProcessor.TestHelpers.Services;
+using Microsoft.EntityFrameworkCore;
+
+namespace LeadProcessor.UnitTests.Infrastructure.Persistence;
+
+///
+/// Unit tests for .
+///
+public class LeadProcessorDbContextTests
+{
+ private readonly FixedDateTimeProvider _dateTimeProvider;
+
+ public LeadProcessorDbContextTests()
+ {
+ _dateTimeProvider = new FixedDateTimeProvider(
+ new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero));
+ }
+
+ ///
+ /// Creates a new in-memory database context for testing.
+ ///
+ private LeadProcessorDbContext CreateContext(string databaseName = "TestDb")
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName)
+ .Options;
+
+ return new LeadProcessorDbContext(options, _dateTimeProvider);
+ }
+
+ [Fact]
+ public async Task SaveChangesAsync_WithNewLead_SavesSuccessfully()
+ {
+ // Arrange
+ await using var context = CreateContext();
+ var lead = new Lead
+ {
+ TenantId = "tenant-123",
+ CorrelationId = Guid.NewGuid().ToString(),
+ Email = "test@example.com",
+ FirstName = "John",
+ LastName = "Doe",
+ Source = "website",
+ CreatedAt = _dateTimeProvider.UtcNow,
+ UpdatedAt = _dateTimeProvider.UtcNow
+ };
+
+ // Act
+ context.Leads.Add(lead);
+ var result = await context.SaveChangesAsync();
+
+ // Assert
+ Assert.Equal(1, result);
+ Assert.Equal(1, await context.Leads.CountAsync());
+ }
+
+ [Fact]
+ public async Task SaveChangesAsync_WithModifiedLead_UpdatesUpdatedAtTimestamp()
+ {
+ // Arrange
+ var databaseName = Guid.NewGuid().ToString();
+ var originalTime = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero);
+ var updatedTime = new DateTimeOffset(2025, 1, 15, 11, 0, 0, TimeSpan.Zero);
+
+ // Create and save initial lead
+ await using (var context = CreateContext(databaseName))
+ {
+ var lead = new Lead
+ {
+ TenantId = "tenant-123",
+ CorrelationId = Guid.NewGuid().ToString(),
+ Email = "test@example.com",
+ Source = "website",
+ CreatedAt = originalTime,
+ UpdatedAt = originalTime
+ };
+ context.Leads.Add(lead);
+ await context.SaveChangesAsync();
+ }
+
+ // Act - Update the lead with a new time provider
+ var newTimeProvider = new FixedDateTimeProvider(updatedTime);
+ await using (var context = new LeadProcessorDbContext(
+ new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName)
+ .Options,
+ newTimeProvider))
+ {
+ var lead = await context.Leads.FirstAsync();
+ var modifiedLead = lead with { Email = "updated@example.com" };
+ context.Entry(lead).CurrentValues.SetValues(modifiedLead);
+ await context.SaveChangesAsync();
+ }
+
+ // Assert
+ await using (var context = CreateContext(databaseName))
+ {
+ var savedLead = await context.Leads.FirstAsync();
+ Assert.Equal("updated@example.com", savedLead.Email);
+ Assert.Equal(originalTime, savedLead.CreatedAt);
+ Assert.Equal(updatedTime, savedLead.UpdatedAt);
+ }
+ }
+
+ [Fact]
+ public void SaveChanges_SynchronousVersion_UpdatesUpdatedAtTimestamp()
+ {
+ // Arrange
+ var databaseName = Guid.NewGuid().ToString();
+ var originalTime = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero);
+ var updatedTime = new DateTimeOffset(2025, 1, 15, 11, 0, 0, TimeSpan.Zero);
+
+ // Create and save initial lead
+ using (var context = CreateContext(databaseName))
+ {
+ var lead = new Lead
+ {
+ TenantId = "tenant-123",
+ CorrelationId = Guid.NewGuid().ToString(),
+ Email = "test@example.com",
+ Source = "website",
+ CreatedAt = originalTime,
+ UpdatedAt = originalTime
+ };
+ context.Leads.Add(lead);
+ context.SaveChanges();
+ }
+
+ // Act - Update the lead with a new time provider
+ var newTimeProvider = new FixedDateTimeProvider(updatedTime);
+ using (var context = new LeadProcessorDbContext(
+ new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName)
+ .Options,
+ newTimeProvider))
+ {
+ var lead = context.Leads.First();
+ var modifiedLead = lead with { Email = "updated@example.com" };
+ context.Entry(lead).CurrentValues.SetValues(modifiedLead);
+ context.SaveChanges();
+ }
+
+ // Assert
+ using (var context = CreateContext(databaseName))
+ {
+ var savedLead = context.Leads.First();
+ Assert.Equal("updated@example.com", savedLead.Email);
+ Assert.Equal(originalTime, savedLead.CreatedAt);
+ Assert.Equal(updatedTime, savedLead.UpdatedAt);
+ }
+ }
+
+ [Fact]
+ public void Leads_DbSet_IsConfiguredCorrectly()
+ {
+ // Arrange
+ using var context = CreateContext();
+
+ // Act
+ var leadsSet = context.Leads;
+
+ // Assert
+ Assert.NotNull(leadsSet);
+ Assert.IsAssignableFrom>(leadsSet);
+ }
+
+ [Fact]
+ public async Task SaveChangesAsync_WithMultipleModifiedLeads_UpdatesAllTimestamps()
+ {
+ // Arrange
+ var databaseName = Guid.NewGuid().ToString();
+ var originalTime = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero);
+ var updatedTime = new DateTimeOffset(2025, 1, 15, 11, 0, 0, TimeSpan.Zero);
+
+ // Create and save multiple leads
+ await using (var context = CreateContext(databaseName))
+ {
+ var leads = new[]
+ {
+ new Lead
+ {
+ TenantId = "tenant-123",
+ CorrelationId = Guid.NewGuid().ToString(),
+ Email = "test1@example.com",
+ Source = "website",
+ CreatedAt = originalTime,
+ UpdatedAt = originalTime
+ },
+ new Lead
+ {
+ TenantId = "tenant-123",
+ CorrelationId = Guid.NewGuid().ToString(),
+ Email = "test2@example.com",
+ Source = "mobile",
+ CreatedAt = originalTime,
+ UpdatedAt = originalTime
+ }
+ };
+ context.Leads.AddRange(leads);
+ await context.SaveChangesAsync();
+ }
+
+ // Act - Update both leads
+ var newTimeProvider = new FixedDateTimeProvider(updatedTime);
+ await using (var context = new LeadProcessorDbContext(
+ new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName)
+ .Options,
+ newTimeProvider))
+ {
+ var leads = await context.Leads.ToListAsync();
+ foreach (var lead in leads)
+ {
+ var modifiedLead = lead with { Phone = "+1234567890" };
+ context.Entry(lead).CurrentValues.SetValues(modifiedLead);
+ }
+ await context.SaveChangesAsync();
+ }
+
+ // Assert
+ await using (var context = CreateContext(databaseName))
+ {
+ var savedLeads = await context.Leads.ToListAsync();
+ Assert.All(savedLeads, lead =>
+ {
+ Assert.Equal("+1234567890", lead.Phone);
+ Assert.Equal(originalTime, lead.CreatedAt);
+ Assert.Equal(updatedTime, lead.UpdatedAt);
+ });
+ }
+ }
+
+ [Fact]
+ public async Task SaveChangesAsync_WithUnmodifiedLead_DoesNotUpdateTimestamp()
+ {
+ // Arrange
+ var databaseName = Guid.NewGuid().ToString();
+ var originalTime = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero);
+
+ // Create and save initial lead
+ await using (var context = CreateContext(databaseName))
+ {
+ var lead = new Lead
+ {
+ TenantId = "tenant-123",
+ CorrelationId = Guid.NewGuid().ToString(),
+ Email = "test@example.com",
+ Source = "website",
+ CreatedAt = originalTime,
+ UpdatedAt = originalTime
+ };
+ context.Leads.Add(lead);
+ await context.SaveChangesAsync();
+ }
+
+ // Act - Just query without modifying
+ var newTimeProvider = new FixedDateTimeProvider(
+ new DateTimeOffset(2025, 1, 15, 11, 0, 0, TimeSpan.Zero));
+ await using (var context = new LeadProcessorDbContext(
+ new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName)
+ .Options,
+ newTimeProvider))
+ {
+ _ = await context.Leads.FirstAsync();
+ await context.SaveChangesAsync();
+ }
+
+ // Assert - Timestamp should remain unchanged
+ await using (var context = CreateContext(databaseName))
+ {
+ var savedLead = await context.Leads.FirstAsync();
+ Assert.Equal(originalTime, savedLead.UpdatedAt);
+ }
+ }
+
+ [Fact]
+ public async Task SaveChangesAsync_WithCancellationToken_SupportsCancellation()
+ {
+ // Arrange
+ await using var context = CreateContext();
+ var lead = new Lead
+ {
+ TenantId = "tenant-123",
+ CorrelationId = Guid.NewGuid().ToString(),
+ Email = "test@example.com",
+ Source = "website",
+ CreatedAt = _dateTimeProvider.UtcNow,
+ UpdatedAt = _dateTimeProvider.UtcNow
+ };
+ context.Leads.Add(lead);
+
+ using var cts = new CancellationTokenSource();
+
+ // Act & Assert - Should complete normally
+ var result = await context.SaveChangesAsync(cts.Token);
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public void Constructor_WithValidParameters_CreatesInstance()
+ {
+ // Arrange
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase("TestDb")
+ .Options;
+
+ // Act
+ var context = new LeadProcessorDbContext(options, _dateTimeProvider);
+
+ // Assert
+ Assert.NotNull(context);
+ Assert.NotNull(context.Leads);
+ }
+
+ [Fact]
+ public async Task DateTimeOffset_Storage_PreservesUtcOffset()
+ {
+ // Arrange
+ var databaseName = Guid.NewGuid().ToString();
+ var utcTime = new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero);
+
+ await using (var context = CreateContext(databaseName))
+ {
+ var lead = new Lead
+ {
+ TenantId = "tenant-123",
+ CorrelationId = Guid.NewGuid().ToString(),
+ Email = "test@example.com",
+ Source = "website",
+ CreatedAt = utcTime,
+ UpdatedAt = utcTime
+ };
+ context.Leads.Add(lead);
+ await context.SaveChangesAsync();
+ }
+
+ // Act
+ await using (var context = CreateContext(databaseName))
+ {
+ var savedLead = await context.Leads.FirstAsync();
+
+ // Assert - Verify UTC offset is preserved
+ Assert.Equal(TimeSpan.Zero, savedLead.CreatedAt.Offset);
+ Assert.Equal(TimeSpan.Zero, savedLead.UpdatedAt.Offset);
+ Assert.Equal(utcTime, savedLead.CreatedAt);
+ Assert.Equal(utcTime, savedLead.UpdatedAt);
+ }
+ }
+}
+
diff --git a/tests/LeadProcessor.UnitTests/Infrastructure/Repositories/LeadRepositoryTests.cs b/tests/LeadProcessor.UnitTests/Infrastructure/Repositories/LeadRepositoryTests.cs
new file mode 100644
index 0000000..04c803f
--- /dev/null
+++ b/tests/LeadProcessor.UnitTests/Infrastructure/Repositories/LeadRepositoryTests.cs
@@ -0,0 +1,501 @@
+using LeadProcessor.Domain.Entities;
+using LeadProcessor.Infrastructure.Persistence;
+using LeadProcessor.Infrastructure.Repositories;
+using LeadProcessor.TestHelpers.Services;
+using Microsoft.EntityFrameworkCore;
+
+namespace LeadProcessor.UnitTests.Infrastructure.Repositories;
+
+///
+/// Unit tests for .
+///
+///
+/// These tests verify the repository's data access operations using EF Core InMemory database.
+/// Each test uses an isolated in-memory database to ensure test independence.
+///
+public sealed class LeadRepositoryTests
+{
+ private static readonly DateTimeOffset FixedDateTime = new(2025, 10, 22, 12, 0, 0, TimeSpan.Zero);
+
+ ///
+ /// Creates a new in-memory database context for testing.
+ /// Each call creates a fresh database with a unique name to ensure test isolation.
+ ///
+ private static LeadProcessorDbContext CreateInMemoryContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+
+ var dateTimeProvider = new FixedDateTimeProvider(FixedDateTime);
+ return new LeadProcessorDbContext(options, dateTimeProvider);
+ }
+
+ ///
+ /// Creates a sample lead entity for testing.
+ ///
+ private static Lead CreateSampleLead(string correlationId = "test-correlation-id") => new()
+ {
+ Email = "john.doe@example.com",
+ FirstName = "John",
+ LastName = "Doe",
+ Phone = "+44 20 1234 5678",
+ Company = "Acme Corp",
+ Source = "WebForm",
+ CorrelationId = correlationId,
+ TenantId = "tenant-123",
+ Metadata = """{"campaign":"spring-2025","utm_source":"google"}""",
+ CreatedAt = FixedDateTime,
+ UpdatedAt = FixedDateTime
+ };
+
+ #region Constructor Tests
+
+ [Fact]
+ public void Constructor_WithValidContext_CreatesInstance()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+
+ // Act
+ var repository = new LeadRepository(context);
+
+ // Assert
+ Assert.NotNull(repository);
+ }
+
+ [Fact]
+ public void Constructor_WithNullContext_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ var exception = Assert.Throws(() => new LeadRepository(null!));
+ Assert.Equal("context", exception.ParamName);
+ }
+
+ #endregion
+
+ #region SaveLeadAsync Tests
+
+ [Fact]
+ public async Task SaveLeadAsync_WithNewLead_InsertsLeadAndReturnsWithId()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var lead = CreateSampleLead();
+
+ // Act
+ var savedLead = await repository.SaveLeadAsync(lead);
+
+ // Assert
+ Assert.NotNull(savedLead);
+ Assert.True(savedLead.Id > 0, "Saved lead should have a positive ID assigned by the database");
+ Assert.Equal(lead.FirstName, savedLead.FirstName);
+ Assert.Equal(lead.Email, savedLead.Email);
+ Assert.Equal(lead.CorrelationId, savedLead.CorrelationId);
+
+ // Verify in database
+ var retrievedLead = await context.Leads.FindAsync(savedLead.Id);
+ Assert.NotNull(retrievedLead);
+ Assert.Equal(savedLead.Id, retrievedLead.Id);
+ Assert.Equal(savedLead.CorrelationId, retrievedLead.CorrelationId);
+ }
+
+ [Fact]
+ public async Task SaveLeadAsync_WithExistingLead_UpdatesLead()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var originalLead = CreateSampleLead();
+ var savedLead = await repository.SaveLeadAsync(originalLead);
+
+ // Create an updated version with the same ID
+ // Repository should handle detaching the tracked entity automatically
+ var updatedLead = savedLead with
+ {
+ FirstName = "Jane",
+ LastName = "Smith",
+ Company = "New Corp"
+ };
+
+ // Act
+ var result = await repository.SaveLeadAsync(updatedLead);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(updatedLead.Id, result.Id);
+ Assert.Equal("Jane", result.FirstName);
+ Assert.Equal("Smith", result.LastName);
+ Assert.Equal("New Corp", result.Company);
+ Assert.Equal(savedLead.CorrelationId, result.CorrelationId);
+
+ // Verify only one record exists
+ var leadCount = await context.Leads.CountAsync();
+ Assert.Equal(1, leadCount);
+ }
+
+ [Fact]
+ public async Task SaveLeadAsync_WithNullLead_ThrowsArgumentNullException()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ async () => await repository.SaveLeadAsync(null!));
+ }
+
+ [Fact]
+ public async Task SaveLeadAsync_WithCancellationToken_SupportsCancellation()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var lead = CreateSampleLead();
+ using var cts = new CancellationTokenSource();
+
+ // Act
+ cts.Cancel();
+
+ // Assert
+ await Assert.ThrowsAnyAsync(
+ async () => await repository.SaveLeadAsync(lead, cts.Token));
+ }
+
+ [Fact]
+ public async Task SaveLeadAsync_WithMultipleLeads_SavesAllIndependently()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var lead1 = CreateSampleLead("correlation-1");
+ var lead2 = CreateSampleLead("correlation-2");
+
+ // Act
+ var savedLead1 = await repository.SaveLeadAsync(lead1);
+ var savedLead2 = await repository.SaveLeadAsync(lead2);
+
+ // Assert
+ Assert.NotEqual(savedLead1.Id, savedLead2.Id);
+ Assert.Equal("correlation-1", savedLead1.CorrelationId);
+ Assert.Equal("correlation-2", savedLead2.CorrelationId);
+
+ var totalLeads = await context.Leads.CountAsync();
+ Assert.Equal(2, totalLeads);
+ }
+
+ #endregion
+
+ #region ExistsByCorrelationIdAsync Tests
+
+ [Fact]
+ public async Task ExistsByCorrelationIdAsync_WithExistingCorrelationId_ReturnsTrue()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var lead = CreateSampleLead("existing-correlation-id");
+ await repository.SaveLeadAsync(lead);
+
+ // Act
+ var exists = await repository.ExistsByCorrelationIdAsync("existing-correlation-id");
+
+ // Assert
+ Assert.True(exists);
+ }
+
+ [Fact]
+ public async Task ExistsByCorrelationIdAsync_WithNonExistentCorrelationId_ReturnsFalse()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act
+ var exists = await repository.ExistsByCorrelationIdAsync("non-existent-correlation-id");
+
+ // Assert
+ Assert.False(exists);
+ }
+
+ [Fact]
+ public async Task ExistsByCorrelationIdAsync_WithNullCorrelationId_ThrowsArgumentException()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ async () => await repository.ExistsByCorrelationIdAsync(null!));
+ Assert.Equal("correlationId", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task ExistsByCorrelationIdAsync_WithWhitespaceCorrelationId_ThrowsArgumentException()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ async () => await repository.ExistsByCorrelationIdAsync(" "));
+ Assert.Equal("correlationId", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task ExistsByCorrelationIdAsync_WithEmptyCorrelationId_ThrowsArgumentException()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ async () => await repository.ExistsByCorrelationIdAsync(string.Empty));
+ Assert.Equal("correlationId", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task ExistsByCorrelationIdAsync_WithCancellationToken_SupportsCancellation()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ using var cts = new CancellationTokenSource();
+
+ // Act
+ cts.Cancel();
+
+ // Assert
+ await Assert.ThrowsAnyAsync(
+ async () => await repository.ExistsByCorrelationIdAsync("test-id", cts.Token));
+ }
+
+ [Fact]
+ public async Task ExistsByCorrelationIdAsync_WithMultipleLeads_FindsCorrectOne()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ await repository.SaveLeadAsync(CreateSampleLead("correlation-1"));
+ await repository.SaveLeadAsync(CreateSampleLead("correlation-2"));
+ await repository.SaveLeadAsync(CreateSampleLead("correlation-3"));
+
+ // Act
+ var exists2 = await repository.ExistsByCorrelationIdAsync("correlation-2");
+ var exists4 = await repository.ExistsByCorrelationIdAsync("correlation-4");
+
+ // Assert
+ Assert.True(exists2);
+ Assert.False(exists4);
+ }
+
+ #endregion
+
+ #region GetByCorrelationIdAsync Tests
+
+ [Fact]
+ public async Task GetByCorrelationIdAsync_WithExistingCorrelationId_ReturnsLead()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var originalLead = CreateSampleLead("existing-correlation-id");
+ await repository.SaveLeadAsync(originalLead);
+
+ // Act
+ var retrievedLead = await repository.GetByCorrelationIdAsync("existing-correlation-id");
+
+ // Assert
+ Assert.NotNull(retrievedLead);
+ Assert.Equal(originalLead.CorrelationId, retrievedLead.CorrelationId);
+ Assert.Equal(originalLead.FirstName, retrievedLead.FirstName);
+ Assert.Equal(originalLead.Email, retrievedLead.Email);
+ Assert.Equal(originalLead.TenantId, retrievedLead.TenantId);
+ }
+
+ [Fact]
+ public async Task GetByCorrelationIdAsync_WithNonExistentCorrelationId_ReturnsNull()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act
+ var retrievedLead = await repository.GetByCorrelationIdAsync("non-existent-correlation-id");
+
+ // Assert
+ Assert.Null(retrievedLead);
+ }
+
+ [Fact]
+ public async Task GetByCorrelationIdAsync_WithNullCorrelationId_ThrowsArgumentException()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ async () => await repository.GetByCorrelationIdAsync(null!));
+ Assert.Equal("correlationId", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task GetByCorrelationIdAsync_WithWhitespaceCorrelationId_ThrowsArgumentException()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ async () => await repository.GetByCorrelationIdAsync(" "));
+ Assert.Equal("correlationId", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task GetByCorrelationIdAsync_WithEmptyCorrelationId_ThrowsArgumentException()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ async () => await repository.GetByCorrelationIdAsync(string.Empty));
+ Assert.Equal("correlationId", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task GetByCorrelationIdAsync_WithCancellationToken_SupportsCancellation()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ using var cts = new CancellationTokenSource();
+
+ // Act
+ cts.Cancel();
+
+ // Assert
+ await Assert.ThrowsAnyAsync(
+ async () => await repository.GetByCorrelationIdAsync("test-id", cts.Token));
+ }
+
+ [Fact]
+ public async Task GetByCorrelationIdAsync_WithMultipleLeads_ReturnsCorrectOne()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var lead1 = CreateSampleLead("correlation-1") with { FirstName = "Alice" };
+ var lead2 = CreateSampleLead("correlation-2") with { FirstName = "Bob" };
+ var lead3 = CreateSampleLead("correlation-3") with { FirstName = "Charlie" };
+ await repository.SaveLeadAsync(lead1);
+ await repository.SaveLeadAsync(lead2);
+ await repository.SaveLeadAsync(lead3);
+
+ // Act
+ var retrievedLead = await repository.GetByCorrelationIdAsync("correlation-2");
+
+ // Assert
+ Assert.NotNull(retrievedLead);
+ Assert.Equal("Bob", retrievedLead.FirstName);
+ Assert.Equal("correlation-2", retrievedLead.CorrelationId);
+ }
+
+ [Fact]
+ public async Task GetByCorrelationIdAsync_RetrievesAllLeadProperties()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var originalLead = CreateSampleLead("full-test-correlation-id");
+ await repository.SaveLeadAsync(originalLead);
+
+ // Act
+ var retrievedLead = await repository.GetByCorrelationIdAsync("full-test-correlation-id");
+
+ // Assert
+ Assert.NotNull(retrievedLead);
+ Assert.Equal(originalLead.Email, retrievedLead.Email);
+ Assert.Equal(originalLead.FirstName, retrievedLead.FirstName);
+ Assert.Equal(originalLead.LastName, retrievedLead.LastName);
+ Assert.Equal(originalLead.Phone, retrievedLead.Phone);
+ Assert.Equal(originalLead.Company, retrievedLead.Company);
+ Assert.Equal(originalLead.Source, retrievedLead.Source);
+ Assert.Equal(originalLead.Metadata, retrievedLead.Metadata);
+ Assert.Equal(originalLead.CorrelationId, retrievedLead.CorrelationId);
+ Assert.Equal(originalLead.TenantId, retrievedLead.TenantId);
+ }
+
+ #endregion
+
+ #region Integration Tests
+
+ [Fact]
+ public async Task Repository_IdempotencyScenario_SaveAndCheckExistence()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var correlationId = "idempotency-test-id";
+
+ // Act & Assert - Lead doesn't exist initially
+ var existsBefore = await repository.ExistsByCorrelationIdAsync(correlationId);
+ Assert.False(existsBefore);
+
+ // Save the lead
+ var lead = CreateSampleLead(correlationId);
+ await repository.SaveLeadAsync(lead);
+
+ // Lead exists after saving
+ var existsAfter = await repository.ExistsByCorrelationIdAsync(correlationId);
+ Assert.True(existsAfter);
+
+ // Can retrieve the lead
+ var retrievedLead = await repository.GetByCorrelationIdAsync(correlationId);
+ Assert.NotNull(retrievedLead);
+ Assert.Equal(correlationId, retrievedLead.CorrelationId);
+ }
+
+ [Fact]
+ public async Task Repository_UpdateScenario_SaveRetrieveUpdateRetrieve()
+ {
+ // Arrange
+ using var context = CreateInMemoryContext();
+ var repository = new LeadRepository(context);
+ var correlationId = "update-test-id";
+
+ // Act - Save initial lead
+ var originalLead = CreateSampleLead(correlationId) with { Source = "WebForm" };
+ var savedLead = await repository.SaveLeadAsync(originalLead);
+ var originalId = savedLead.Id;
+
+ // Retrieve the lead
+ var retrievedLead = await repository.GetByCorrelationIdAsync(correlationId);
+ Assert.NotNull(retrievedLead);
+ Assert.Equal("WebForm", retrievedLead.Source);
+
+ // Update the lead - repository should handle any tracking conflicts
+ var updatedLead = retrievedLead with { Source = "EmailCampaign" };
+ await repository.SaveLeadAsync(updatedLead);
+
+ // Retrieve again
+ var finalLead = await repository.GetByCorrelationIdAsync(correlationId);
+
+ // Assert
+ Assert.NotNull(finalLead);
+ Assert.Equal(originalId, finalLead.Id);
+ Assert.Equal("EmailCampaign", finalLead.Source);
+ Assert.Equal(correlationId, finalLead.CorrelationId);
+ }
+
+ #endregion
+}
+
diff --git a/tests/LeadProcessor.UnitTests/Infrastructure/Services/DbConnectionStringProviderTests.cs b/tests/LeadProcessor.UnitTests/Infrastructure/Services/DbConnectionStringProviderTests.cs
new file mode 100644
index 0000000..1480669
--- /dev/null
+++ b/tests/LeadProcessor.UnitTests/Infrastructure/Services/DbConnectionStringProviderTests.cs
@@ -0,0 +1,313 @@
+using LeadProcessor.Infrastructure.Services;
+
+namespace LeadProcessor.UnitTests.Infrastructure.Services;
+
+///
+/// Unit tests for .
+///
+public sealed class DbConnectionStringProviderTests
+{
+ #region Initialization Tests
+
+ [Fact]
+ public void Initialize_WithValidConnectionString_StoresConnectionString()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string connectionString = "Server=localhost;Port=3306;Database=test;User=root;Password=password";
+
+ // Act
+ provider.Initialize(connectionString);
+ var result = provider.GetConnectionString();
+
+ // Assert
+ Assert.Equal(connectionString, result);
+ }
+
+ [Fact]
+ public void Initialize_WithNullConnectionString_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+
+ // Act & Assert
+ var exception = Assert.Throws(() => provider.Initialize(null!));
+ Assert.Equal("connectionString", exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("\t")]
+ [InlineData("\n")]
+ public void Initialize_WithEmptyOrWhitespaceConnectionString_ThrowsArgumentException(string invalidConnectionString)
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+
+ // Act & Assert
+ var exception = Assert.Throws(() => provider.Initialize(invalidConnectionString));
+ Assert.Equal("connectionString", exception.ParamName);
+ Assert.Contains("cannot be empty or whitespace", exception.Message);
+ }
+
+ [Fact]
+ public void Initialize_CalledMultipleTimes_OverwritesPreviousValue()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string firstConnectionString = "Server=localhost;Port=3306;Database=test1;User=root;Password=password1";
+ const string secondConnectionString = "Server=localhost;Port=3306;Database=test2;User=root;Password=password2";
+
+ // Act
+ provider.Initialize(firstConnectionString);
+ provider.Initialize(secondConnectionString);
+ var result = provider.GetConnectionString();
+
+ // Assert
+ Assert.Equal(secondConnectionString, result);
+ }
+
+ #endregion
+
+ #region GetConnectionString Tests
+
+ [Fact]
+ public void GetConnectionString_BeforeInitialization_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+
+ // Act & Assert
+ var exception = Assert.Throws(() => provider.GetConnectionString());
+ Assert.Contains("not initialized", exception.Message);
+ Assert.Contains("Initialize()", exception.Message);
+ }
+
+ [Fact]
+ public void GetConnectionString_AfterInitialization_ReturnsConnectionString()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string connectionString = "Server=localhost;Port=3306;Database=test;User=root;Password=password";
+ provider.Initialize(connectionString);
+
+ // Act
+ var result = provider.GetConnectionString();
+
+ // Assert
+ Assert.Equal(connectionString, result);
+ }
+
+ [Fact]
+ public void GetConnectionString_CalledMultipleTimes_ReturnsConsistentValue()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string connectionString = "Server=localhost;Port=3306;Database=test;User=root;Password=password";
+ provider.Initialize(connectionString);
+
+ // Act
+ var result1 = provider.GetConnectionString();
+ var result2 = provider.GetConnectionString();
+ var result3 = provider.GetConnectionString();
+
+ // Assert
+ Assert.Equal(connectionString, result1);
+ Assert.Equal(connectionString, result2);
+ Assert.Equal(connectionString, result3);
+ }
+
+ #endregion
+
+ #region Thread Safety Tests
+
+ [Fact]
+ public void Initialize_CalledConcurrently_IsThreadSafe()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const int threadCount = 10;
+ var tasks = new Task[threadCount];
+ var connectionStrings = Enumerable.Range(0, threadCount)
+ .Select(i => $"Server=localhost;Port=3306;Database=test{i};User=root;Password=password{i}")
+ .ToArray();
+
+ // Act
+ for (int i = 0; i < threadCount; i++)
+ {
+ int index = i;
+ tasks[i] = Task.Run(() => provider.Initialize(connectionStrings[index]));
+ }
+
+ Task.WaitAll(tasks);
+
+ // Assert - Should not throw and should contain one of the connection strings
+ var result = provider.GetConnectionString();
+ Assert.NotNull(result);
+ Assert.Contains(result, connectionStrings);
+ }
+
+ [Fact]
+ public void GetConnectionString_CalledConcurrently_IsThreadSafe()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string connectionString = "Server=localhost;Port=3306;Database=test;User=root;Password=password";
+ provider.Initialize(connectionString);
+
+ const int threadCount = 20;
+ var tasks = new Task[threadCount];
+
+ // Act
+ for (int i = 0; i < threadCount; i++)
+ {
+ tasks[i] = Task.Run(() => provider.GetConnectionString());
+ }
+
+ Task.WaitAll(tasks);
+
+ // Assert - All threads should get the same value
+ Assert.All(tasks, task => Assert.Equal(connectionString, task.Result));
+ }
+
+ [Fact]
+ public void InitializeAndGetConnectionString_CalledConcurrently_IsThreadSafe()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string initialConnectionString = "Server=localhost;Port=3306;Database=test;User=root;Password=password";
+ provider.Initialize(initialConnectionString);
+
+ const int readThreadCount = 10;
+ const int writeThreadCount = 5;
+ var allTasks = new List();
+
+ // Act - Mix of reads and writes
+ for (int i = 0; i < writeThreadCount; i++)
+ {
+ int index = i;
+ allTasks.Add(Task.Run(() =>
+ {
+ var newConnectionString = $"Server=localhost;Port=3306;Database=test{index};User=root;Password=password{index}";
+ provider.Initialize(newConnectionString);
+ }));
+ }
+
+ for (int i = 0; i < readThreadCount; i++)
+ {
+ allTasks.Add(Task.Run(() =>
+ {
+ var result = provider.GetConnectionString();
+ Assert.NotNull(result);
+ Assert.NotEmpty(result);
+ }));
+ }
+
+ // Wait for all operations to complete
+ Task.WaitAll(allTasks.ToArray());
+
+ // Assert - Should not throw and should have a valid connection string
+ var finalResult = provider.GetConnectionString();
+ Assert.NotNull(finalResult);
+ Assert.NotEmpty(finalResult);
+ }
+
+ #endregion
+
+ #region Edge Cases Tests
+
+ [Fact]
+ public void Initialize_WithLongConnectionString_StoresCorrectly()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ var longConnectionString = "Server=very-long-hostname-that-could-be-in-aws-rds.c9akciq32.us-east-1.rds.amazonaws.com;" +
+ "Port=3306;" +
+ "Database=leadprocessor_production_database;" +
+ "User=application_user_with_long_name;" +
+ "Password=very_long_and_complex_password_with_special_chars_!@#$%^&*()";
+
+ // Act
+ provider.Initialize(longConnectionString);
+ var result = provider.GetConnectionString();
+
+ // Assert
+ Assert.Equal(longConnectionString, result);
+ }
+
+ [Fact]
+ public void Initialize_WithSpecialCharacters_StoresCorrectly()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string connectionString = "Server=localhost;Port=3306;Database=test;User=root;Password=p@$$w0rd!#;";
+
+ // Act
+ provider.Initialize(connectionString);
+ var result = provider.GetConnectionString();
+
+ // Assert
+ Assert.Equal(connectionString, result);
+ }
+
+ [Fact]
+ public void Initialize_WithMinimalConnectionString_StoresCorrectly()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string connectionString = "Server=localhost";
+
+ // Act
+ provider.Initialize(connectionString);
+ var result = provider.GetConnectionString();
+
+ // Assert
+ Assert.Equal(connectionString, result);
+ }
+
+ #endregion
+
+ #region Lifecycle Tests
+
+ [Fact]
+ public void Provider_InitializeGetReInitializeGet_WorksCorrectly()
+ {
+ // Arrange
+ var provider = new DbConnectionStringProvider();
+ const string connectionString1 = "Server=localhost;Port=3306;Database=test1;User=root;Password=password1";
+ const string connectionString2 = "Server=localhost;Port=3306;Database=test2;User=root;Password=password2";
+
+ // Act & Assert - First initialization
+ provider.Initialize(connectionString1);
+ Assert.Equal(connectionString1, provider.GetConnectionString());
+
+ // Act & Assert - Re-initialization
+ provider.Initialize(connectionString2);
+ Assert.Equal(connectionString2, provider.GetConnectionString());
+
+ // Act & Assert - Verify it stays with the latest value
+ Assert.Equal(connectionString2, provider.GetConnectionString());
+ }
+
+ [Fact]
+ public void MultipleProviders_AreIndependent()
+ {
+ // Arrange
+ var provider1 = new DbConnectionStringProvider();
+ var provider2 = new DbConnectionStringProvider();
+ const string connectionString1 = "Server=localhost;Port=3306;Database=test1;User=root;Password=password1";
+ const string connectionString2 = "Server=localhost;Port=3306;Database=test2;User=root;Password=password2";
+
+ // Act
+ provider1.Initialize(connectionString1);
+ provider2.Initialize(connectionString2);
+
+ // Assert
+ Assert.Equal(connectionString1, provider1.GetConnectionString());
+ Assert.Equal(connectionString2, provider2.GetConnectionString());
+ }
+
+ #endregion
+}
+
diff --git a/tests/LeadProcessor.UnitTests/Infrastructure/Services/FixedDateTimeProviderTests.cs b/tests/LeadProcessor.UnitTests/Infrastructure/Services/FixedDateTimeProviderTests.cs
new file mode 100644
index 0000000..def50b5
--- /dev/null
+++ b/tests/LeadProcessor.UnitTests/Infrastructure/Services/FixedDateTimeProviderTests.cs
@@ -0,0 +1,123 @@
+using LeadProcessor.TestHelpers.Services;
+
+namespace LeadProcessor.UnitTests.Infrastructure.Services;
+
+///
+/// Unit tests for .
+///
+public class FixedDateTimeProviderTests
+{
+ [Fact]
+ public void UtcNow_ReturnsFixedDateTime()
+ {
+ // Arrange
+ var fixedTime = new DateTimeOffset(2025, 1, 15, 10, 30, 45, TimeSpan.Zero);
+ var provider = new FixedDateTimeProvider(fixedTime);
+
+ // Act
+ var result = provider.UtcNow;
+
+ // Assert
+ Assert.Equal(fixedTime, result);
+ }
+
+ [Fact]
+ public void UtcNow_CalledMultipleTimes_ReturnsConsistentValue()
+ {
+ // Arrange
+ var fixedTime = new DateTimeOffset(2025, 1, 15, 10, 30, 45, TimeSpan.Zero);
+ var provider = new FixedDateTimeProvider(fixedTime);
+
+ // Act
+ var first = provider.UtcNow;
+ Thread.Sleep(50); // Ensure time would have progressed if not fixed
+ var second = provider.UtcNow;
+ var third = provider.UtcNow;
+
+ // Assert
+ Assert.Equal(fixedTime, first);
+ Assert.Equal(fixedTime, second);
+ Assert.Equal(fixedTime, third);
+ Assert.Equal(first, second);
+ Assert.Equal(second, third);
+ }
+
+ [Fact]
+ public void UtcNow_PreservesUtcOffset()
+ {
+ // Arrange
+ var fixedTime = new DateTimeOffset(2025, 1, 15, 10, 30, 45, TimeSpan.Zero);
+ var provider = new FixedDateTimeProvider(fixedTime);
+
+ // Act
+ var result = provider.UtcNow;
+
+ // Assert
+ Assert.Equal(TimeSpan.Zero, result.Offset);
+ }
+
+ [Fact]
+ public void Constructor_WithNonUtcOffset_StillReturnsProvidedValue()
+ {
+ // Arrange - Test that provider accepts any offset (though UTC is recommended)
+ var fixedTime = new DateTimeOffset(2025, 1, 15, 10, 30, 45, TimeSpan.FromHours(5));
+ var provider = new FixedDateTimeProvider(fixedTime);
+
+ // Act
+ var result = provider.UtcNow;
+
+ // Assert
+ Assert.Equal(fixedTime, result);
+ Assert.Equal(TimeSpan.FromHours(5), result.Offset);
+ }
+
+ [Fact]
+ public void UtcNow_IsThreadSafe()
+ {
+ // Arrange
+ var fixedTime = new DateTimeOffset(2025, 1, 15, 10, 30, 45, TimeSpan.Zero);
+ var provider = new FixedDateTimeProvider(fixedTime);
+ var results = new List();
+ var tasks = new List();
+ var lockObject = new object();
+
+ // Act - Call from multiple threads concurrently
+ for (int i = 0; i < 10; i++)
+ {
+ tasks.Add(Task.Run(() =>
+ {
+ var result = provider.UtcNow;
+ lock (lockObject)
+ {
+ results.Add(result);
+ }
+ }));
+ }
+
+ Task.WaitAll(tasks.ToArray());
+
+ // Assert
+ Assert.Equal(10, results.Count);
+ Assert.All(results, r => Assert.Equal(fixedTime, r));
+ }
+
+ [Fact]
+ public void Constructor_DifferentInstances_ReturnDifferentFixedTimes()
+ {
+ // Arrange
+ var firstTime = new DateTimeOffset(2025, 1, 15, 10, 30, 45, TimeSpan.Zero);
+ var secondTime = new DateTimeOffset(2025, 2, 20, 14, 15, 30, TimeSpan.Zero);
+ var firstProvider = new FixedDateTimeProvider(firstTime);
+ var secondProvider = new FixedDateTimeProvider(secondTime);
+
+ // Act
+ var firstResult = firstProvider.UtcNow;
+ var secondResult = secondProvider.UtcNow;
+
+ // Assert
+ Assert.Equal(firstTime, firstResult);
+ Assert.Equal(secondTime, secondResult);
+ Assert.NotEqual(firstResult, secondResult);
+ }
+}
+
diff --git a/tests/LeadProcessor.UnitTests/Infrastructure/Services/SecretsManagerServiceTests.cs b/tests/LeadProcessor.UnitTests/Infrastructure/Services/SecretsManagerServiceTests.cs
new file mode 100644
index 0000000..9ecd8d6
--- /dev/null
+++ b/tests/LeadProcessor.UnitTests/Infrastructure/Services/SecretsManagerServiceTests.cs
@@ -0,0 +1,119 @@
+using Cloudvelous.Aws.SecretsManager;
+using LeadProcessor.Infrastructure.Models;
+using LeadProcessor.Infrastructure.Services;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace LeadProcessor.UnitTests.Infrastructure.Services;
+
+///
+/// Unit tests for using Cloudvelous SDK.
+///
+///
+/// These tests verify the wrapper service correctly delegates to ISecretsManagerClient.
+/// Note: Due to Cloudvelous SDK's use of optional parameters in GetSecretValueAsync,
+/// we cannot use Moq's expression trees for verification. These tests focus on
+/// argument validation and exception handling behavior.
+///
+public sealed class SecretsManagerServiceTests
+{
+ private readonly Mock> _mockLogger;
+
+ public SecretsManagerServiceTests()
+ {
+ _mockLogger = new Mock>();
+ }
+
+ #region Constructor Tests
+
+ [Fact]
+ public void Constructor_WithNullSecretsManagerClient_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ new SecretsManagerService(null!, _mockLogger.Object));
+ Assert.Equal("secretsManagerClient", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithNullLogger_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ new SecretsManagerService(mockClient.Object, null!));
+ Assert.Equal("logger", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithValidParameters_CreatesInstance()
+ {
+ // Arrange
+ var mockClient = new Mock();
+
+ // Act
+ var service = new SecretsManagerService(mockClient.Object, _mockLogger.Object);
+
+ // Assert
+ Assert.NotNull(service);
+ }
+
+ #endregion
+
+ #region GetDatabaseCredentialsAsync Tests
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public async Task GetDatabaseCredentialsAsync_WithInvalidSecretName_ThrowsArgumentException(string invalidSecretName)
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new SecretsManagerService(mockClient.Object, _mockLogger.Object);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ service.GetDatabaseCredentialsAsync(invalidSecretName));
+ Assert.Equal("secretName", exception.ParamName);
+ Assert.Contains("cannot be null or whitespace", exception.Message);
+ }
+
+ #endregion
+
+ #region GetSecretAsync Tests
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public async Task GetSecretAsync_WithInvalidSecretName_ThrowsArgumentException(string invalidSecretName)
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new SecretsManagerService(mockClient.Object, _mockLogger.Object);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ service.GetSecretAsync(invalidSecretName));
+ Assert.Equal("secretName", exception.ParamName);
+ Assert.Contains("cannot be null or whitespace", exception.Message);
+ }
+
+ #endregion
+
+ #region Test Helper Classes
+
+ ///
+ /// Test configuration class for generic secret retrieval tests.
+ ///
+ private sealed class TestConfig
+ {
+ public string ApiKey { get; set; } = string.Empty;
+ public string Endpoint { get; set; } = string.Empty;
+ }
+
+ #endregion
+}
diff --git a/tests/LeadProcessor.UnitTests/Infrastructure/Services/SystemDateTimeProviderTests.cs b/tests/LeadProcessor.UnitTests/Infrastructure/Services/SystemDateTimeProviderTests.cs
new file mode 100644
index 0000000..bdf5e38
--- /dev/null
+++ b/tests/LeadProcessor.UnitTests/Infrastructure/Services/SystemDateTimeProviderTests.cs
@@ -0,0 +1,83 @@
+using LeadProcessor.Infrastructure.Services;
+
+namespace LeadProcessor.UnitTests.Infrastructure.Services;
+
+///
+/// Unit tests for .
+///
+public class SystemDateTimeProviderTests
+{
+ [Fact]
+ public void UtcNow_ReturnsCurrentUtcTime()
+ {
+ // Arrange
+ var provider = new SystemDateTimeProvider();
+ var beforeCall = DateTimeOffset.UtcNow;
+
+ // Act
+ var result = provider.UtcNow;
+
+ // Assert
+ var afterCall = DateTimeOffset.UtcNow;
+ Assert.InRange(result, beforeCall.AddMilliseconds(-10), afterCall.AddMilliseconds(10));
+ Assert.Equal(TimeSpan.Zero, result.Offset); // Verify UTC offset
+ }
+
+ [Fact]
+ public void UtcNow_ReturnsUtcOffset()
+ {
+ // Arrange
+ var provider = new SystemDateTimeProvider();
+
+ // Act
+ var result = provider.UtcNow;
+
+ // Assert
+ Assert.Equal(TimeSpan.Zero, result.Offset);
+ }
+
+ [Fact]
+ public void UtcNow_CalledMultipleTimes_ReturnsProgressingTime()
+ {
+ // Arrange
+ var provider = new SystemDateTimeProvider();
+
+ // Act
+ var first = provider.UtcNow;
+ Thread.Sleep(50); // Small delay to ensure time progresses
+ var second = provider.UtcNow;
+
+ // Assert
+ Assert.True(second >= first, "Second call should return time >= first call");
+ }
+
+ [Fact]
+ public void UtcNow_IsThreadSafe()
+ {
+ // Arrange
+ var provider = new SystemDateTimeProvider();
+ var results = new List();
+ var tasks = new List();
+ var lockObject = new object();
+
+ // Act - Call from multiple threads concurrently
+ for (int i = 0; i < 10; i++)
+ {
+ tasks.Add(Task.Run(() =>
+ {
+ var result = provider.UtcNow;
+ lock (lockObject)
+ {
+ results.Add(result);
+ }
+ }));
+ }
+
+ Task.WaitAll(tasks.ToArray());
+
+ // Assert
+ Assert.Equal(10, results.Count);
+ Assert.All(results, r => Assert.Equal(TimeSpan.Zero, r.Offset));
+ }
+}
+
diff --git a/tests/LeadProcessor.UnitTests/LeadProcessor.UnitTests.csproj b/tests/LeadProcessor.UnitTests/LeadProcessor.UnitTests.csproj
index edd06d9..4cfe55c 100644
--- a/tests/LeadProcessor.UnitTests/LeadProcessor.UnitTests.csproj
+++ b/tests/LeadProcessor.UnitTests/LeadProcessor.UnitTests.csproj
@@ -10,6 +10,7 @@
+
@@ -25,6 +26,7 @@
+