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,544 @@
|
||||
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 digest aggregation (PG-T3.10.4).
|
||||
/// Tests the complete digest lifecycle: collection → aggregation → sending → cleanup.
|
||||
/// </summary>
|
||||
[Collection(NotifyPostgresCollection.Name)]
|
||||
public sealed class DigestAggregationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NotifyPostgresFixture _fixture;
|
||||
private readonly DigestRepository _digestRepository;
|
||||
private readonly ChannelRepository _channelRepository;
|
||||
private readonly QuietHoursRepository _quietHoursRepository;
|
||||
private readonly MaintenanceWindowRepository _maintenanceRepository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public DigestAggregationTests(NotifyPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
|
||||
|
||||
_digestRepository = new DigestRepository(dataSource, NullLogger<DigestRepository>.Instance);
|
||||
_channelRepository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
|
||||
_quietHoursRepository = new QuietHoursRepository(dataSource, NullLogger<QuietHoursRepository>.Instance);
|
||||
_maintenanceRepository = new MaintenanceWindowRepository(dataSource, NullLogger<MaintenanceWindowRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_CompleteLifecycle_CollectingToSent()
|
||||
{
|
||||
// Arrange - Create channel
|
||||
var channel = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "daily-digest-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
await _channelRepository.CreateAsync(channel);
|
||||
|
||||
// Create digest in collecting state
|
||||
var digest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "user@example.com",
|
||||
DigestKey = "daily-vulnerabilities",
|
||||
EventCount = 0,
|
||||
Events = "[]",
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
await _digestRepository.UpsertAsync(digest);
|
||||
|
||||
// Act - Add events to digest
|
||||
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0001"}""");
|
||||
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0002"}""");
|
||||
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0003"}""");
|
||||
|
||||
var afterEvents = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
|
||||
afterEvents!.EventCount.Should().Be(3);
|
||||
afterEvents.Events.Should().Contain("CVE-2025-0001");
|
||||
afterEvents.Events.Should().Contain("CVE-2025-0002");
|
||||
afterEvents.Events.Should().Contain("CVE-2025-0003");
|
||||
|
||||
// Transition to sending
|
||||
await _digestRepository.MarkSendingAsync(_tenantId, digest.Id);
|
||||
var sending = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
|
||||
sending!.Status.Should().Be(DigestStatus.Sending);
|
||||
|
||||
// Transition to sent
|
||||
await _digestRepository.MarkSentAsync(_tenantId, digest.Id);
|
||||
var sent = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
|
||||
sent!.Status.Should().Be(DigestStatus.Sent);
|
||||
sent.SentAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_GetReadyToSend_ReturnsExpiredCollectingDigests()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "ready-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
await _channelRepository.CreateAsync(channel);
|
||||
|
||||
// Create digest that's ready (collect window passed)
|
||||
var readyDigest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "ready@example.com",
|
||||
DigestKey = "ready-digest",
|
||||
EventCount = 5,
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddMinutes(-5) // Past the collection window
|
||||
};
|
||||
|
||||
// Create digest that's not ready (still collecting)
|
||||
var notReadyDigest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "notready@example.com",
|
||||
DigestKey = "notready-digest",
|
||||
EventCount = 3,
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1) // Still collecting
|
||||
};
|
||||
|
||||
await _digestRepository.UpsertAsync(readyDigest);
|
||||
await _digestRepository.UpsertAsync(notReadyDigest);
|
||||
|
||||
// Act
|
||||
var ready = await _digestRepository.GetReadyToSendAsync();
|
||||
|
||||
// Assert
|
||||
ready.Should().Contain(d => d.Id == readyDigest.Id);
|
||||
ready.Should().NotContain(d => d.Id == notReadyDigest.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_GetByKey_ReturnsExistingDigest()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "key-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
await _channelRepository.CreateAsync(channel);
|
||||
|
||||
var digestKey = "hourly-alerts";
|
||||
var recipient = "alerts@example.com";
|
||||
var digest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = recipient,
|
||||
DigestKey = digestKey,
|
||||
EventCount = 2,
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
await _digestRepository.UpsertAsync(digest);
|
||||
|
||||
// Act
|
||||
var fetched = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, recipient, digestKey);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(digest.Id);
|
||||
fetched.DigestKey.Should().Be(digestKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_Upsert_UpdatesExistingDigest()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "upsert-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
await _channelRepository.CreateAsync(channel);
|
||||
|
||||
var digest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "upsert@example.com",
|
||||
DigestKey = "upsert-key",
|
||||
EventCount = 1,
|
||||
Events = """[{"event": 1}]""",
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
await _digestRepository.UpsertAsync(digest);
|
||||
|
||||
// Act - Upsert with updated collect window
|
||||
var updated = new DigestEntity
|
||||
{
|
||||
Id = digest.Id,
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "upsert@example.com",
|
||||
DigestKey = "upsert-key",
|
||||
EventCount = digest.EventCount,
|
||||
Events = digest.Events,
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(2) // Extended
|
||||
};
|
||||
await _digestRepository.UpsertAsync(updated);
|
||||
|
||||
// Assert
|
||||
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
|
||||
fetched!.CollectUntil.Should().BeCloseTo(DateTimeOffset.UtcNow.AddHours(2), TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_DeleteOld_RemovesSentDigests()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "cleanup-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
await _channelRepository.CreateAsync(channel);
|
||||
|
||||
// Create old sent digest
|
||||
var oldDigest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "old@example.com",
|
||||
DigestKey = "old-digest",
|
||||
Status = DigestStatus.Sent,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddDays(-10)
|
||||
};
|
||||
await _digestRepository.UpsertAsync(oldDigest);
|
||||
await _digestRepository.MarkSentAsync(_tenantId, oldDigest.Id);
|
||||
|
||||
// Create recent digest
|
||||
var recentDigest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "recent@example.com",
|
||||
DigestKey = "recent-digest",
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
await _digestRepository.UpsertAsync(recentDigest);
|
||||
|
||||
// Act - Delete digests older than 7 days
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-7);
|
||||
var deleted = await _digestRepository.DeleteOldAsync(cutoff);
|
||||
|
||||
// Assert
|
||||
deleted.Should().BeGreaterThanOrEqualTo(1);
|
||||
var oldFetch = await _digestRepository.GetByIdAsync(_tenantId, oldDigest.Id);
|
||||
oldFetch.Should().BeNull();
|
||||
|
||||
var recentFetch = await _digestRepository.GetByIdAsync(_tenantId, recentDigest.Id);
|
||||
recentFetch.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_MultipleRecipients_SeparateDigests()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "multi-recipient-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
await _channelRepository.CreateAsync(channel);
|
||||
|
||||
var digestKey = "shared-key";
|
||||
var digest1 = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "user1@example.com",
|
||||
DigestKey = digestKey,
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
var digest2 = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "user2@example.com",
|
||||
DigestKey = digestKey,
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
await _digestRepository.UpsertAsync(digest1);
|
||||
await _digestRepository.UpsertAsync(digest2);
|
||||
|
||||
// Act
|
||||
var fetched1 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user1@example.com", digestKey);
|
||||
var fetched2 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user2@example.com", digestKey);
|
||||
|
||||
// Assert
|
||||
fetched1.Should().NotBeNull();
|
||||
fetched2.Should().NotBeNull();
|
||||
fetched1!.Id.Should().NotBe(fetched2!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_EventAccumulation_AppendsToArray()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "accumulate-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
await _channelRepository.CreateAsync(channel);
|
||||
|
||||
var digest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = "accumulate@example.com",
|
||||
DigestKey = "accumulate-key",
|
||||
EventCount = 0,
|
||||
Events = "[]",
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
await _digestRepository.UpsertAsync(digest);
|
||||
|
||||
// Act - Add 10 events
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
await _digestRepository.AddEventAsync(_tenantId, digest.Id, $$$"""{"id": {{{i}}}, "type": "scan.finding"}""");
|
||||
}
|
||||
|
||||
// Assert
|
||||
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
|
||||
fetched!.EventCount.Should().Be(10);
|
||||
|
||||
// Parse events JSON to verify all events are there
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
fetched.Events.Should().Contain($"\"id\": {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_QuietHoursIntegration_RespectsSilencePeriod()
|
||||
{
|
||||
// Arrange - Create quiet hours config
|
||||
var userId = Guid.NewGuid();
|
||||
var quietHours = new QuietHoursEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
UserId = userId,
|
||||
StartTime = new TimeOnly(22, 0), // 10 PM
|
||||
EndTime = new TimeOnly(7, 0), // 7 AM
|
||||
Timezone = "UTC",
|
||||
DaysOfWeek = [1, 2, 3, 4, 5], // Weekdays
|
||||
Enabled = true
|
||||
};
|
||||
await _quietHoursRepository.CreateAsync(quietHours);
|
||||
|
||||
// Act
|
||||
var fetched = await _quietHoursRepository.GetForUserAsync(_tenantId, userId);
|
||||
|
||||
// Assert
|
||||
fetched.Should().ContainSingle();
|
||||
fetched[0].StartTime.Should().Be(new TimeOnly(22, 0));
|
||||
fetched[0].EndTime.Should().Be(new TimeOnly(7, 0));
|
||||
fetched[0].Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_MaintenanceWindowIntegration_RespectsWindow()
|
||||
{
|
||||
// Arrange - Create maintenance window
|
||||
var suppressChannel = Guid.NewGuid();
|
||||
var window = new MaintenanceWindowEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "weekly-maintenance",
|
||||
Description = "Weekly system maintenance window",
|
||||
StartAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
EndAt = DateTimeOffset.UtcNow.AddDays(1).AddHours(2),
|
||||
SuppressChannels = [suppressChannel],
|
||||
SuppressEventTypes = ["scan.completed", "vulnerability.detected"]
|
||||
};
|
||||
await _maintenanceRepository.CreateAsync(window);
|
||||
|
||||
// Act
|
||||
var active = await _maintenanceRepository.GetActiveAsync(_tenantId);
|
||||
|
||||
// Assert - No active windows right now since it's scheduled for tomorrow
|
||||
active.Should().BeEmpty();
|
||||
|
||||
var all = await _maintenanceRepository.ListAsync(_tenantId);
|
||||
all.Should().ContainSingle(w => w.Id == window.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Digest_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 digests
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var digest = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
ChannelId = channel.Id,
|
||||
Recipient = $"user{i}@example.com",
|
||||
DigestKey = $"key-{i}",
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddMinutes(-i) // All ready
|
||||
};
|
||||
await _digestRepository.UpsertAsync(digest);
|
||||
}
|
||||
|
||||
// Act
|
||||
var results1 = await _digestRepository.GetReadyToSendAsync(limit: 100);
|
||||
var results2 = await _digestRepository.GetReadyToSendAsync(limit: 100);
|
||||
var results3 = await _digestRepository.GetReadyToSendAsync(limit: 100);
|
||||
|
||||
// Assert
|
||||
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 Digest_MultiTenantIsolation_NoLeakage()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1 = Guid.NewGuid().ToString();
|
||||
var tenant2 = Guid.NewGuid().ToString();
|
||||
|
||||
var channel1 = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenant1,
|
||||
Name = "tenant1-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
var channel2 = new ChannelEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenant2,
|
||||
Name = "tenant2-channel",
|
||||
ChannelType = ChannelType.Email,
|
||||
Enabled = true
|
||||
};
|
||||
await _channelRepository.CreateAsync(channel1);
|
||||
await _channelRepository.CreateAsync(channel2);
|
||||
|
||||
var digest1 = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenant1,
|
||||
ChannelId = channel1.Id,
|
||||
Recipient = "user@tenant1.com",
|
||||
DigestKey = "shared-key",
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
var digest2 = new DigestEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenant2,
|
||||
ChannelId = channel2.Id,
|
||||
Recipient = "user@tenant2.com",
|
||||
DigestKey = "shared-key",
|
||||
Status = DigestStatus.Collecting,
|
||||
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
await _digestRepository.UpsertAsync(digest1);
|
||||
await _digestRepository.UpsertAsync(digest2);
|
||||
|
||||
// Act
|
||||
var tenant1Fetch = await _digestRepository.GetByKeyAsync(tenant1, channel1.Id, "user@tenant1.com", "shared-key");
|
||||
var tenant2Fetch = await _digestRepository.GetByKeyAsync(tenant2, channel2.Id, "user@tenant2.com", "shared-key");
|
||||
|
||||
// Cross-tenant attempts should fail
|
||||
var crossFetch1 = await _digestRepository.GetByIdAsync(tenant1, digest2.Id);
|
||||
var crossFetch2 = await _digestRepository.GetByIdAsync(tenant2, digest1.Id);
|
||||
|
||||
// Assert
|
||||
tenant1Fetch.Should().NotBeNull();
|
||||
tenant1Fetch!.TenantId.Should().Be(tenant1);
|
||||
|
||||
tenant2Fetch.Should().NotBeNull();
|
||||
tenant2Fetch!.TenantId.Should().Be(tenant2);
|
||||
|
||||
crossFetch1.Should().BeNull();
|
||||
crossFetch2.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
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 escalation handling (PG-T3.10.3).
|
||||
/// Tests the complete escalation lifecycle: policy creation → state tracking → escalation progression → resolution.
|
||||
/// </summary>
|
||||
[Collection(NotifyPostgresCollection.Name)]
|
||||
public sealed class EscalationHandlingTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NotifyPostgresFixture _fixture;
|
||||
private readonly EscalationPolicyRepository _policyRepository;
|
||||
private readonly EscalationStateRepository _stateRepository;
|
||||
private readonly OnCallScheduleRepository _onCallRepository;
|
||||
private readonly IncidentRepository _incidentRepository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public EscalationHandlingTests(NotifyPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
|
||||
|
||||
_policyRepository = new EscalationPolicyRepository(dataSource, NullLogger<EscalationPolicyRepository>.Instance);
|
||||
_stateRepository = new EscalationStateRepository(dataSource, NullLogger<EscalationStateRepository>.Instance);
|
||||
_onCallRepository = new OnCallScheduleRepository(dataSource, NullLogger<OnCallScheduleRepository>.Instance);
|
||||
_incidentRepository = new IncidentRepository(dataSource, NullLogger<IncidentRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_CompleteLifecycle_ActiveToResolved()
|
||||
{
|
||||
// Arrange - Create escalation policy with multiple steps
|
||||
var policy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "critical-incident-policy",
|
||||
Description = "Escalation policy for critical incidents",
|
||||
Enabled = true,
|
||||
Steps = """
|
||||
[
|
||||
{"step": 1, "delay_minutes": 5, "channels": ["email"], "targets": ["oncall-primary"]},
|
||||
{"step": 2, "delay_minutes": 10, "channels": ["email", "sms"], "targets": ["oncall-secondary"]},
|
||||
{"step": 3, "delay_minutes": 15, "channels": ["phone"], "targets": ["manager"]}
|
||||
]
|
||||
""",
|
||||
RepeatCount = 2
|
||||
};
|
||||
await _policyRepository.CreateAsync(policy);
|
||||
|
||||
// Create incident
|
||||
var incident = new IncidentEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Title = "Critical vulnerability detected",
|
||||
Severity = "critical",
|
||||
Status = IncidentStatus.Open,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
await _incidentRepository.CreateAsync(incident);
|
||||
|
||||
// Act - Start escalation
|
||||
var escalationState = new EscalationStateEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PolicyId = policy.Id,
|
||||
IncidentId = incident.Id,
|
||||
CorrelationId = incident.CorrelationId,
|
||||
CurrentStep = 1,
|
||||
Status = EscalationStatus.Active,
|
||||
StartedAt = DateTimeOffset.UtcNow,
|
||||
NextEscalationAt = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
};
|
||||
await _stateRepository.CreateAsync(escalationState);
|
||||
|
||||
// Verify active
|
||||
var active = await _stateRepository.GetActiveAsync();
|
||||
active.Should().Contain(s => s.Id == escalationState.Id);
|
||||
|
||||
// Escalate to step 2
|
||||
await _stateRepository.EscalateAsync(
|
||||
_tenantId,
|
||||
escalationState.Id,
|
||||
newStep: 2,
|
||||
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(10));
|
||||
|
||||
var afterStep2 = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
|
||||
afterStep2!.CurrentStep.Should().Be(2);
|
||||
afterStep2.Status.Should().Be(EscalationStatus.Active);
|
||||
|
||||
// Acknowledge
|
||||
await _stateRepository.AcknowledgeAsync(_tenantId, escalationState.Id, "oncall@example.com");
|
||||
var acknowledged = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
|
||||
acknowledged!.Status.Should().Be(EscalationStatus.Acknowledged);
|
||||
acknowledged.AcknowledgedBy.Should().Be("oncall@example.com");
|
||||
acknowledged.AcknowledgedAt.Should().NotBeNull();
|
||||
|
||||
// Resolve
|
||||
await _stateRepository.ResolveAsync(_tenantId, escalationState.Id, "responder@example.com");
|
||||
var resolved = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
|
||||
resolved!.Status.Should().Be(EscalationStatus.Resolved);
|
||||
resolved.ResolvedBy.Should().Be("responder@example.com");
|
||||
resolved.ResolvedAt.Should().NotBeNull();
|
||||
|
||||
// No longer in active list
|
||||
var finalActive = await _stateRepository.GetActiveAsync();
|
||||
finalActive.Should().NotContain(s => s.Id == escalationState.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_MultiStepProgression_TracksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "multi-step-policy",
|
||||
Enabled = true,
|
||||
Steps = """[{"step": 1}, {"step": 2}, {"step": 3}, {"step": 4}]""",
|
||||
RepeatCount = 0
|
||||
};
|
||||
await _policyRepository.CreateAsync(policy);
|
||||
|
||||
var state = new EscalationStateEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PolicyId = policy.Id,
|
||||
CorrelationId = Guid.NewGuid().ToString(),
|
||||
CurrentStep = 1,
|
||||
Status = EscalationStatus.Active,
|
||||
StartedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _stateRepository.CreateAsync(state);
|
||||
|
||||
// Act - Progress through all steps
|
||||
for (int step = 2; step <= 4; step++)
|
||||
{
|
||||
await _stateRepository.EscalateAsync(
|
||||
_tenantId,
|
||||
state.Id,
|
||||
newStep: step,
|
||||
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(step * 5));
|
||||
|
||||
var current = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
|
||||
current!.CurrentStep.Should().Be(step);
|
||||
}
|
||||
|
||||
// Assert final state
|
||||
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
|
||||
final!.CurrentStep.Should().Be(4);
|
||||
final.Status.Should().Be(EscalationStatus.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_GetByCorrelation_RetrievesCorrectState()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "correlation-test-policy",
|
||||
Enabled = true
|
||||
};
|
||||
await _policyRepository.CreateAsync(policy);
|
||||
|
||||
var correlationId = $"incident-{Guid.NewGuid():N}";
|
||||
var state = new EscalationStateEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PolicyId = policy.Id,
|
||||
CorrelationId = correlationId,
|
||||
CurrentStep = 1,
|
||||
Status = EscalationStatus.Active,
|
||||
StartedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _stateRepository.CreateAsync(state);
|
||||
|
||||
// Act
|
||||
var fetched = await _stateRepository.GetByCorrelationIdAsync(_tenantId, correlationId);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(state.Id);
|
||||
fetched.CorrelationId.Should().Be(correlationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_OnCallScheduleIntegration_FindsCorrectResponder()
|
||||
{
|
||||
// Arrange - Create on-call schedules
|
||||
var primarySchedule = new OnCallScheduleEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "primary-oncall",
|
||||
RotationType = RotationType.Weekly,
|
||||
Participants = """["alice@example.com", "bob@example.com"]""",
|
||||
Timezone = "UTC"
|
||||
};
|
||||
var secondarySchedule = new OnCallScheduleEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "secondary-oncall",
|
||||
RotationType = RotationType.Weekly,
|
||||
Participants = """["charlie@example.com", "diana@example.com"]""",
|
||||
Timezone = "UTC"
|
||||
};
|
||||
await _onCallRepository.CreateAsync(primarySchedule);
|
||||
await _onCallRepository.CreateAsync(secondarySchedule);
|
||||
|
||||
// Act
|
||||
var primary = await _onCallRepository.GetByNameAsync(_tenantId, "primary-oncall");
|
||||
var secondary = await _onCallRepository.GetByNameAsync(_tenantId, "secondary-oncall");
|
||||
|
||||
// Assert
|
||||
primary.Should().NotBeNull();
|
||||
primary!.Participants.Should().Contain("alice@example.com");
|
||||
secondary.Should().NotBeNull();
|
||||
secondary!.Participants.Should().Contain("charlie@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_MultipleActiveStates_AllTracked()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "multi-active-policy",
|
||||
Enabled = true
|
||||
};
|
||||
await _policyRepository.CreateAsync(policy);
|
||||
|
||||
// Create multiple active escalations
|
||||
var states = new List<EscalationStateEntity>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var state = new EscalationStateEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PolicyId = policy.Id,
|
||||
CorrelationId = $"incident-{i}-{Guid.NewGuid():N}",
|
||||
CurrentStep = 1,
|
||||
Status = EscalationStatus.Active,
|
||||
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-i)
|
||||
};
|
||||
await _stateRepository.CreateAsync(state);
|
||||
states.Add(state);
|
||||
}
|
||||
|
||||
// Act
|
||||
var active = await _stateRepository.GetActiveAsync(limit: 100);
|
||||
|
||||
// Assert
|
||||
foreach (var state in states)
|
||||
{
|
||||
active.Should().Contain(s => s.Id == state.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_PolicyDisabled_NotUsed()
|
||||
{
|
||||
// Arrange
|
||||
var enabledPolicy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "enabled-policy",
|
||||
Enabled = true
|
||||
};
|
||||
var disabledPolicy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "disabled-policy",
|
||||
Enabled = false
|
||||
};
|
||||
await _policyRepository.CreateAsync(enabledPolicy);
|
||||
await _policyRepository.CreateAsync(disabledPolicy);
|
||||
|
||||
// Act
|
||||
var allPolicies = await _policyRepository.ListAsync(_tenantId);
|
||||
var enabledOnly = allPolicies.Where(p => p.Enabled).ToList();
|
||||
|
||||
// Assert
|
||||
allPolicies.Should().HaveCount(2);
|
||||
enabledOnly.Should().ContainSingle(p => p.Id == enabledPolicy.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_IncidentLinking_TracksAssociation()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "incident-linked-policy",
|
||||
Enabled = true
|
||||
};
|
||||
await _policyRepository.CreateAsync(policy);
|
||||
|
||||
var incident = new IncidentEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Title = "Security Breach",
|
||||
Severity = "critical",
|
||||
Status = IncidentStatus.Open,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
await _incidentRepository.CreateAsync(incident);
|
||||
|
||||
var state = new EscalationStateEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PolicyId = policy.Id,
|
||||
IncidentId = incident.Id,
|
||||
CorrelationId = incident.CorrelationId,
|
||||
CurrentStep = 1,
|
||||
Status = EscalationStatus.Active,
|
||||
StartedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _stateRepository.CreateAsync(state);
|
||||
|
||||
// Act
|
||||
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.IncidentId.Should().Be(incident.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_RepeatIteration_TracksRepeats()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "repeating-policy",
|
||||
Enabled = true,
|
||||
RepeatCount = 3
|
||||
};
|
||||
await _policyRepository.CreateAsync(policy);
|
||||
|
||||
// Act - Create state at repeat iteration 2
|
||||
var state = new EscalationStateEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PolicyId = policy.Id,
|
||||
CorrelationId = Guid.NewGuid().ToString(),
|
||||
CurrentStep = 1,
|
||||
RepeatIteration = 2,
|
||||
Status = EscalationStatus.Active,
|
||||
StartedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _stateRepository.CreateAsync(state);
|
||||
|
||||
// Assert
|
||||
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
|
||||
fetched!.RepeatIteration.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_DeterministicOrdering_ConsistentResults()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "determinism-policy",
|
||||
Enabled = true
|
||||
};
|
||||
await _policyRepository.CreateAsync(policy);
|
||||
|
||||
// Create multiple active escalations
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _stateRepository.CreateAsync(new EscalationStateEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PolicyId = policy.Id,
|
||||
CorrelationId = $"det-{i}-{Guid.NewGuid():N}",
|
||||
CurrentStep = 1,
|
||||
Status = EscalationStatus.Active,
|
||||
StartedAt = DateTimeOffset.UtcNow.AddSeconds(i)
|
||||
});
|
||||
}
|
||||
|
||||
// Act
|
||||
var results1 = await _stateRepository.GetActiveAsync(limit: 100);
|
||||
var results2 = await _stateRepository.GetActiveAsync(limit: 100);
|
||||
var results3 = await _stateRepository.GetActiveAsync(limit: 100);
|
||||
|
||||
// Assert
|
||||
var ids1 = results1.Select(s => s.Id).ToList();
|
||||
var ids2 = results2.Select(s => s.Id).ToList();
|
||||
var ids3 = results3.Select(s => s.Id).ToList();
|
||||
|
||||
ids1.Should().Equal(ids2);
|
||||
ids2.Should().Equal(ids3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Escalation_Metadata_PreservedThroughLifecycle()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new EscalationPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "metadata-policy",
|
||||
Enabled = true,
|
||||
Metadata = """{"severity_levels": ["low", "medium", "high", "critical"]}"""
|
||||
};
|
||||
await _policyRepository.CreateAsync(policy);
|
||||
|
||||
var state = new EscalationStateEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PolicyId = policy.Id,
|
||||
CorrelationId = Guid.NewGuid().ToString(),
|
||||
CurrentStep = 1,
|
||||
Status = EscalationStatus.Active,
|
||||
StartedAt = DateTimeOffset.UtcNow,
|
||||
Metadata = """{"original_severity": "critical", "source": "scanner"}"""
|
||||
};
|
||||
await _stateRepository.CreateAsync(state);
|
||||
|
||||
// Act
|
||||
await _stateRepository.EscalateAsync(_tenantId, state.Id, 2, DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
await _stateRepository.AcknowledgeAsync(_tenantId, state.Id, "responder");
|
||||
await _stateRepository.ResolveAsync(_tenantId, state.Id, "responder");
|
||||
|
||||
// Assert
|
||||
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
|
||||
final!.Metadata.Should().Contain("original_severity");
|
||||
final.Metadata.Should().Contain("scanner");
|
||||
}
|
||||
}
|
||||
@@ -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