Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
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;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for notification delivery flow (PG-T3.10.2).
|
||||
/// Tests the complete lifecycle: channel creation → rule matching → template rendering → delivery tracking.
|
||||
/// </summary>
|
||||
[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<NotifyDataSource>.Instance);
|
||||
|
||||
_channelRepository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
|
||||
_ruleRepository = new RuleRepository(dataSource, NullLogger<RuleRepository>.Instance);
|
||||
_templateRepository = new TemplateRepository(dataSource, NullLogger<TemplateRepository>.Instance);
|
||||
_deliveryRepository = new DeliveryRepository(dataSource, NullLogger<DeliveryRepository>.Instance);
|
||||
_auditRepository = new NotifyAuditRepository(dataSource, NullLogger<NotifyAuditRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user