using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Notify.Storage.Postgres.Models; using StellaOps.Notify.Storage.Postgres.Repositories; using Xunit; using StellaOps.TestKit; namespace StellaOps.Notify.Storage.Postgres.Tests; /// /// End-to-end tests for notification delivery flow (PG-T3.10.2). /// Tests the complete lifecycle: channel creation → rule matching → template rendering → delivery tracking. /// [Collection(NotifyPostgresCollection.Name)] public sealed class NotificationDeliveryFlowTests : IAsyncLifetime { private readonly NotifyPostgresFixture _fixture; private readonly ChannelRepository _channelRepository; private readonly RuleRepository _ruleRepository; private readonly TemplateRepository _templateRepository; private readonly DeliveryRepository _deliveryRepository; private readonly NotifyAuditRepository _auditRepository; private readonly string _tenantId = Guid.NewGuid().ToString(); public NotificationDeliveryFlowTests(NotifyPostgresFixture fixture) { _fixture = fixture; var options = fixture.Fixture.CreateOptions(); options.SchemaName = fixture.SchemaName; var dataSource = new NotifyDataSource(Options.Create(options), NullLogger.Instance); _channelRepository = new ChannelRepository(dataSource, NullLogger.Instance); _ruleRepository = new RuleRepository(dataSource, NullLogger.Instance); _templateRepository = new TemplateRepository(dataSource, NullLogger.Instance); _deliveryRepository = new DeliveryRepository(dataSource, NullLogger.Instance); _auditRepository = new NotifyAuditRepository(dataSource, NullLogger.Instance); } public Task InitializeAsync() => _fixture.TruncateAllTablesAsync(); public Task DisposeAsync() => Task.CompletedTask; [Trait("Category", TestCategories.Unit)] [Fact] public async Task DeliveryFlow_CompleteLifecycle_PendingToDelivered() { // Arrange - Create channel var channel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "email-notifications", ChannelType = ChannelType.Email, Config = """{"smtp_host": "smtp.example.com", "from": "noreply@example.com"}""", Enabled = true }; await _channelRepository.CreateAsync(channel); // Create template var template = new TemplateEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "scan-complete", ChannelType = ChannelType.Email, SubjectTemplate = "Scan Complete: {{scan_name}}", BodyTemplate = "Your scan {{scan_name}} has completed with {{finding_count}} findings.", Locale = "en" }; await _templateRepository.CreateAsync(template); // Create rule var rule = new RuleEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "scan-completed-rule", ChannelIds = [channel.Id], TemplateId = template.Id, EventTypes = ["scan.completed"], Filter = """{"severity": ["high", "critical"]}""", Enabled = true, Priority = 100 }; await _ruleRepository.CreateAsync(rule); // Act - Create delivery var delivery = new DeliveryEntity { Id = Guid.NewGuid(), TenantId = _tenantId, ChannelId = channel.Id, RuleId = rule.Id, TemplateId = template.Id, Recipient = "security@example.com", EventType = "scan.completed", EventPayload = "{\"scan_name\": \"weekly-scan\", \"finding_count\": 42}", Subject = "Scan Complete: weekly-scan", Body = "Your scan weekly-scan has completed with 42 findings.", CorrelationId = Guid.NewGuid().ToString(), Status = DeliveryStatus.Pending }; await _deliveryRepository.CreateAsync(delivery); // Verify pending var pending = await _deliveryRepository.GetPendingAsync(_tenantId); pending.Should().ContainSingle(d => d.Id == delivery.Id); // Progress through lifecycle: Pending → Queued await _deliveryRepository.MarkQueuedAsync(_tenantId, delivery.Id); var queued = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id); queued!.Status.Should().Be(DeliveryStatus.Queued); queued.QueuedAt.Should().NotBeNull(); // Queued → Sent await _deliveryRepository.MarkSentAsync(_tenantId, delivery.Id, "smtp-msg-12345"); var sent = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id); sent!.Status.Should().Be(DeliveryStatus.Sent); sent.ExternalId.Should().Be("smtp-msg-12345"); sent.SentAt.Should().NotBeNull(); // Sent → Delivered await _deliveryRepository.MarkDeliveredAsync(_tenantId, delivery.Id); var delivered = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id); delivered!.Status.Should().Be(DeliveryStatus.Delivered); delivered.DeliveredAt.Should().NotBeNull(); // Verify no longer in pending queue var finalPending = await _deliveryRepository.GetPendingAsync(_tenantId); finalPending.Should().NotContain(d => d.Id == delivery.Id); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DeliveryFlow_FailureAndRetry_TracksErrorState() { // Arrange var channel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "slack-channel", ChannelType = ChannelType.Slack, Config = """{"webhook_url": "https://hooks.slack.com/services/xxx"}""", Enabled = true }; await _channelRepository.CreateAsync(channel); var delivery = new DeliveryEntity { Id = Guid.NewGuid(), TenantId = _tenantId, ChannelId = channel.Id, Recipient = "#security-alerts", EventType = "vulnerability.detected", Status = DeliveryStatus.Pending }; await _deliveryRepository.CreateAsync(delivery); // Act - Mark as failed with retry await _deliveryRepository.MarkFailedAsync(_tenantId, delivery.Id, "Connection refused", TimeSpan.FromMinutes(5)); // Assert var failed = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id); failed!.Status.Should().Be(DeliveryStatus.Failed); failed.ErrorMessage.Should().Be("Connection refused"); failed.FailedAt.Should().NotBeNull(); failed.NextRetryAt.Should().NotBeNull(); failed.Attempt.Should().BeGreaterThanOrEqualTo(1); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DeliveryFlow_MultipleChannels_IndependentDeliveries() { // Arrange - Create two channels var emailChannel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "email", ChannelType = ChannelType.Email, Enabled = true }; var slackChannel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "slack", ChannelType = ChannelType.Slack, Enabled = true }; await _channelRepository.CreateAsync(emailChannel); await _channelRepository.CreateAsync(slackChannel); var correlationId = Guid.NewGuid().ToString(); // Create deliveries for both channels with same correlation var emailDelivery = new DeliveryEntity { Id = Guid.NewGuid(), TenantId = _tenantId, ChannelId = emailChannel.Id, Recipient = "user@example.com", EventType = "alert", CorrelationId = correlationId, Status = DeliveryStatus.Pending }; var slackDelivery = new DeliveryEntity { Id = Guid.NewGuid(), TenantId = _tenantId, ChannelId = slackChannel.Id, Recipient = "#alerts", EventType = "alert", CorrelationId = correlationId, Status = DeliveryStatus.Pending }; await _deliveryRepository.CreateAsync(emailDelivery); await _deliveryRepository.CreateAsync(slackDelivery); // Act - Email succeeds, Slack fails await _deliveryRepository.MarkSentAsync(_tenantId, emailDelivery.Id); await _deliveryRepository.MarkDeliveredAsync(_tenantId, emailDelivery.Id); await _deliveryRepository.MarkFailedAsync(_tenantId, slackDelivery.Id, "Rate limited"); // Assert - Both tracked via correlation var correlated = await _deliveryRepository.GetByCorrelationIdAsync(_tenantId, correlationId); correlated.Should().HaveCount(2); var email = correlated.First(d => d.ChannelId == emailChannel.Id); var slack = correlated.First(d => d.ChannelId == slackChannel.Id); email.Status.Should().Be(DeliveryStatus.Delivered); slack.Status.Should().Be(DeliveryStatus.Failed); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DeliveryFlow_StatsAccumulation_CorrectAggregates() { // Arrange var channel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "stats-channel", ChannelType = ChannelType.Email, Enabled = true }; await _channelRepository.CreateAsync(channel); // Create multiple deliveries in different states // Note: DeliveryStats tracks Pending, Sent, Delivered, Failed, Bounced (no Queued) var deliveries = new[] { (DeliveryStatus.Pending, (string?)null), (DeliveryStatus.Pending, (string?)null), (DeliveryStatus.Pending, (string?)null), (DeliveryStatus.Sent, (string?)null), (DeliveryStatus.Sent, (string?)null), (DeliveryStatus.Sent, (string?)null), (DeliveryStatus.Delivered, (string?)null), (DeliveryStatus.Delivered, (string?)null), (DeliveryStatus.Failed, "Error 1"), (DeliveryStatus.Failed, "Error 2") }; foreach (var (status, error) in deliveries) { var d = new DeliveryEntity { Id = Guid.NewGuid(), TenantId = _tenantId, ChannelId = channel.Id, Recipient = "user@example.com", EventType = "test", Status = status, ErrorMessage = error }; await _deliveryRepository.CreateAsync(d); } // Act var from = DateTimeOffset.UtcNow.AddHours(-1); var to = DateTimeOffset.UtcNow.AddHours(1); var stats = await _deliveryRepository.GetStatsAsync(_tenantId, from, to); // Assert stats.Total.Should().Be(10); stats.Pending.Should().Be(3); stats.Sent.Should().Be(3); stats.Delivered.Should().Be(2); stats.Failed.Should().Be(2); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DeliveryFlow_DeterministicOrdering_ConsistentResults() { // Arrange var channel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "determinism-channel", ChannelType = ChannelType.Email, Enabled = true }; await _channelRepository.CreateAsync(channel); // Create multiple pending deliveries for (int i = 0; i < 10; i++) { await _deliveryRepository.CreateAsync(new DeliveryEntity { Id = Guid.NewGuid(), TenantId = _tenantId, ChannelId = channel.Id, Recipient = $"user{i}@example.com", EventType = "test", Status = DeliveryStatus.Pending }); } // Act - Query multiple times var results1 = await _deliveryRepository.GetPendingAsync(_tenantId); var results2 = await _deliveryRepository.GetPendingAsync(_tenantId); var results3 = await _deliveryRepository.GetPendingAsync(_tenantId); // Assert - Order should be deterministic var ids1 = results1.Select(d => d.Id).ToList(); var ids2 = results2.Select(d => d.Id).ToList(); var ids3 = results3.Select(d => d.Id).ToList(); ids1.Should().Equal(ids2); ids2.Should().Equal(ids3); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DeliveryFlow_AuditTrail_RecordsActions() { // Arrange var channel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "audited-channel", ChannelType = ChannelType.Email, Enabled = true }; await _channelRepository.CreateAsync(channel); // Act - Record audit events await _auditRepository.CreateAsync(new NotifyAuditEntity { TenantId = _tenantId, Action = "channel.created", ResourceType = "channel", ResourceId = channel.Id.ToString(), UserId = null, Details = "{\"name\": \"audited-channel\"}" }); await _auditRepository.CreateAsync(new NotifyAuditEntity { TenantId = _tenantId, Action = "delivery.sent", ResourceType = "delivery", ResourceId = Guid.NewGuid().ToString(), Details = "{\"recipient\": \"user@example.com\"}" }); // Assert var audits = await _auditRepository.GetByResourceAsync(_tenantId, "channel", channel.Id.ToString()); audits.Should().ContainSingle(); audits[0].Action.Should().Be("channel.created"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DeliveryFlow_DisabledChannel_NotQueried() { // Arrange var enabledChannel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "enabled", ChannelType = ChannelType.Email, Enabled = true }; var disabledChannel = new ChannelEntity { Id = Guid.NewGuid(), TenantId = _tenantId, Name = "disabled", ChannelType = ChannelType.Email, Enabled = false }; await _channelRepository.CreateAsync(enabledChannel); await _channelRepository.CreateAsync(disabledChannel); // Act - Get all channels filtered by enabled=true var enabled = await _channelRepository.GetAllAsync(_tenantId, enabled: true); // Assert enabled.Should().ContainSingle(c => c.Id == enabledChannel.Id); enabled.Should().NotContain(c => c.Id == disabledChannel.Id); } }