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);
}
}