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 @@ +