up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-29 11:08:08 +02:00
parent 7e7be4d2fd
commit 3488b22c0c
102 changed files with 18487 additions and 969 deletions

View File

@@ -0,0 +1,204 @@
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;
[Collection(NotifyPostgresCollection.Name)]
public sealed class ChannelRepositoryTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly ChannelRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public ChannelRepositoryTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_repository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetById_RoundTripsChannel()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "email-primary",
ChannelType = ChannelType.Email,
Enabled = true,
Config = "{\"smtpHost\": \"smtp.example.com\"}"
};
// Act
await _repository.CreateAsync(channel);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(channel.Id);
fetched.Name.Should().Be("email-primary");
fetched.ChannelType.Should().Be(ChannelType.Email);
}
[Fact]
public async Task GetByName_ReturnsCorrectChannel()
{
// Arrange
var channel = CreateChannel("slack-alerts", ChannelType.Slack);
await _repository.CreateAsync(channel);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "slack-alerts");
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(channel.Id);
}
[Fact]
public async Task GetAll_ReturnsAllChannelsForTenant()
{
// Arrange
var channel1 = CreateChannel("channel1", ChannelType.Email);
var channel2 = CreateChannel("channel2", ChannelType.Slack);
await _repository.CreateAsync(channel1);
await _repository.CreateAsync(channel2);
// Act
var channels = await _repository.GetAllAsync(_tenantId);
// Assert
channels.Should().HaveCount(2);
channels.Select(c => c.Name).Should().Contain(["channel1", "channel2"]);
}
[Fact]
public async Task GetAll_FiltersByEnabled()
{
// Arrange
var enabledChannel = CreateChannel("enabled", ChannelType.Email);
var disabledChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "disabled",
ChannelType = ChannelType.Email,
Enabled = false
};
await _repository.CreateAsync(enabledChannel);
await _repository.CreateAsync(disabledChannel);
// Act
var enabledChannels = await _repository.GetAllAsync(_tenantId, enabled: true);
// Assert
enabledChannels.Should().HaveCount(1);
enabledChannels[0].Name.Should().Be("enabled");
}
[Fact]
public async Task GetAll_FiltersByChannelType()
{
// Arrange
var emailChannel = CreateChannel("email", ChannelType.Email);
var slackChannel = CreateChannel("slack", ChannelType.Slack);
await _repository.CreateAsync(emailChannel);
await _repository.CreateAsync(slackChannel);
// Act
var slackChannels = await _repository.GetAllAsync(_tenantId, channelType: ChannelType.Slack);
// Assert
slackChannels.Should().HaveCount(1);
slackChannels[0].Name.Should().Be("slack");
}
[Fact]
public async Task Update_ModifiesChannel()
{
// Arrange
var channel = CreateChannel("update-test", ChannelType.Email);
await _repository.CreateAsync(channel);
// Act
var updated = new ChannelEntity
{
Id = channel.Id,
TenantId = _tenantId,
Name = "update-test",
ChannelType = ChannelType.Email,
Enabled = false,
Config = "{\"updated\": true}"
};
var result = await _repository.UpdateAsync(updated);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id);
// Assert
result.Should().BeTrue();
fetched!.Enabled.Should().BeFalse();
fetched.Config.Should().Contain("updated");
}
[Fact]
public async Task Delete_RemovesChannel()
{
// Arrange
var channel = CreateChannel("delete-test", ChannelType.Email);
await _repository.CreateAsync(channel);
// Act
var result = await _repository.DeleteAsync(_tenantId, channel.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id);
// Assert
result.Should().BeTrue();
fetched.Should().BeNull();
}
[Fact]
public async Task GetEnabledByType_ReturnsOnlyEnabledChannelsOfType()
{
// Arrange
var enabledEmail = CreateChannel("enabled-email", ChannelType.Email);
var disabledEmail = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "disabled-email",
ChannelType = ChannelType.Email,
Enabled = false
};
var enabledSlack = CreateChannel("enabled-slack", ChannelType.Slack);
await _repository.CreateAsync(enabledEmail);
await _repository.CreateAsync(disabledEmail);
await _repository.CreateAsync(enabledSlack);
// Act
var channels = await _repository.GetEnabledByTypeAsync(_tenantId, ChannelType.Email);
// Assert
channels.Should().HaveCount(1);
channels[0].Name.Should().Be("enabled-email");
}
private ChannelEntity CreateChannel(string name, ChannelType type) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = name,
ChannelType = type,
Enabled = true
};
}

View File

@@ -0,0 +1,204 @@
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;
[Collection(NotifyPostgresCollection.Name)]
public sealed class DeliveryRepositoryTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly DeliveryRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public DeliveryRepositoryTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_repository = new DeliveryRepository(dataSource, NullLogger<DeliveryRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetById_RoundTripsDelivery()
{
// Arrange
var delivery = CreateDelivery();
// Act
await _repository.CreateAsync(delivery);
var fetched = await _repository.GetByIdAsync(_tenantId, delivery.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(delivery.Id);
fetched.Recipient.Should().Be("user@example.com");
fetched.Status.Should().Be(DeliveryStatus.Pending);
}
[Fact]
public async Task GetPending_ReturnsPendingDeliveries()
{
// Arrange
var pending = CreateDelivery();
await _repository.CreateAsync(pending);
// Act
var pendingDeliveries = await _repository.GetPendingAsync(_tenantId);
// Assert
pendingDeliveries.Should().HaveCount(1);
pendingDeliveries[0].Id.Should().Be(pending.Id);
}
[Fact]
public async Task GetByStatus_ReturnsDeliveriesWithStatus()
{
// Arrange
var delivery = CreateDelivery();
await _repository.CreateAsync(delivery);
// Act
var deliveries = await _repository.GetByStatusAsync(_tenantId, DeliveryStatus.Pending);
// Assert
deliveries.Should().HaveCount(1);
deliveries[0].Status.Should().Be(DeliveryStatus.Pending);
}
[Fact]
public async Task GetByCorrelationId_ReturnsCorrelatedDeliveries()
{
// Arrange
var correlationId = Guid.NewGuid().ToString();
var delivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = Guid.NewGuid(),
Recipient = "user@example.com",
EventType = "scan.completed",
CorrelationId = correlationId
};
await _repository.CreateAsync(delivery);
// Act
var deliveries = await _repository.GetByCorrelationIdAsync(_tenantId, correlationId);
// Assert
deliveries.Should().HaveCount(1);
deliveries[0].CorrelationId.Should().Be(correlationId);
}
[Fact]
public async Task MarkQueued_UpdatesStatus()
{
// Arrange
var delivery = CreateDelivery();
await _repository.CreateAsync(delivery);
// Act
var result = await _repository.MarkQueuedAsync(_tenantId, delivery.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, delivery.Id);
// Assert
result.Should().BeTrue();
fetched!.Status.Should().Be(DeliveryStatus.Queued);
fetched.QueuedAt.Should().NotBeNull();
}
[Fact]
public async Task MarkSent_UpdatesStatusAndExternalId()
{
// Arrange
var delivery = CreateDelivery();
await _repository.CreateAsync(delivery);
await _repository.MarkQueuedAsync(_tenantId, delivery.Id);
// Act
var result = await _repository.MarkSentAsync(_tenantId, delivery.Id, "external-123");
var fetched = await _repository.GetByIdAsync(_tenantId, delivery.Id);
// Assert
result.Should().BeTrue();
fetched!.Status.Should().Be(DeliveryStatus.Sent);
fetched.ExternalId.Should().Be("external-123");
fetched.SentAt.Should().NotBeNull();
}
[Fact]
public async Task MarkDelivered_UpdatesStatus()
{
// Arrange
var delivery = CreateDelivery();
await _repository.CreateAsync(delivery);
await _repository.MarkSentAsync(_tenantId, delivery.Id);
// Act
var result = await _repository.MarkDeliveredAsync(_tenantId, delivery.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, delivery.Id);
// Assert
result.Should().BeTrue();
fetched!.Status.Should().Be(DeliveryStatus.Delivered);
fetched.DeliveredAt.Should().NotBeNull();
}
[Fact]
public async Task MarkFailed_UpdatesStatusAndError()
{
// Arrange
var delivery = CreateDelivery();
await _repository.CreateAsync(delivery);
// Act
var result = await _repository.MarkFailedAsync(_tenantId, delivery.Id, "Connection timeout", TimeSpan.FromMinutes(5));
var fetched = await _repository.GetByIdAsync(_tenantId, delivery.Id);
// Assert
result.Should().BeTrue();
fetched!.Status.Should().Be(DeliveryStatus.Failed);
fetched.ErrorMessage.Should().Be("Connection timeout");
fetched.FailedAt.Should().NotBeNull();
}
[Fact]
public async Task GetStats_ReturnsCorrectCounts()
{
// Arrange
var delivery1 = CreateDelivery();
var delivery2 = CreateDelivery();
await _repository.CreateAsync(delivery1);
await _repository.CreateAsync(delivery2);
await _repository.MarkSentAsync(_tenantId, delivery2.Id);
var from = DateTimeOffset.UtcNow.AddHours(-1);
var to = DateTimeOffset.UtcNow.AddHours(1);
// Act
var stats = await _repository.GetStatsAsync(_tenantId, from, to);
// Assert
stats.Total.Should().Be(2);
stats.Pending.Should().Be(1);
stats.Sent.Should().Be(1);
}
private DeliveryEntity CreateDelivery() => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = Guid.NewGuid(),
Recipient = "user@example.com",
EventType = "scan.completed",
Status = DeliveryStatus.Pending
};
}

View File

@@ -0,0 +1,191 @@
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;
[Collection(NotifyPostgresCollection.Name)]
public sealed class DigestRepositoryTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly DigestRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public DigestRepositoryTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_repository = new DigestRepository(dataSource, NullLogger<DigestRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAndGetById_RoundTripsDigest()
{
// Arrange
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = Guid.NewGuid(),
Recipient = "user@example.com",
DigestKey = "daily-summary",
EventCount = 0,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
// Act
await _repository.UpsertAsync(digest);
var fetched = await _repository.GetByIdAsync(_tenantId, digest.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(digest.Id);
fetched.DigestKey.Should().Be("daily-summary");
fetched.Status.Should().Be(DigestStatus.Collecting);
}
[Fact]
public async Task GetByKey_ReturnsCorrectDigest()
{
// Arrange
var channelId = Guid.NewGuid();
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channelId,
Recipient = "user@example.com",
DigestKey = "weekly-report",
CollectUntil = DateTimeOffset.UtcNow.AddDays(7)
};
await _repository.UpsertAsync(digest);
// Act
var fetched = await _repository.GetByKeyAsync(_tenantId, channelId, "user@example.com", "weekly-report");
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(digest.Id);
}
[Fact]
public async Task AddEvent_IncrementsEventCount()
{
// Arrange
var digest = CreateDigest("event-test");
await _repository.UpsertAsync(digest);
// Act
await _repository.AddEventAsync(_tenantId, digest.Id, "{\"type\": \"test\"}");
await _repository.AddEventAsync(_tenantId, digest.Id, "{\"type\": \"test2\"}");
var fetched = await _repository.GetByIdAsync(_tenantId, digest.Id);
// Assert
fetched!.EventCount.Should().Be(2);
}
[Fact]
public async Task GetReadyToSend_ReturnsDigestsReadyToSend()
{
// Arrange - One ready digest (past CollectUntil), one not ready
var readyDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = Guid.NewGuid(),
Recipient = "ready@example.com",
DigestKey = "ready",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddMinutes(-1)
};
var notReadyDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = Guid.NewGuid(),
Recipient = "notready@example.com",
DigestKey = "notready",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _repository.UpsertAsync(readyDigest);
await _repository.UpsertAsync(notReadyDigest);
// Act
var readyDigests = await _repository.GetReadyToSendAsync();
// Assert
readyDigests.Should().HaveCount(1);
readyDigests[0].DigestKey.Should().Be("ready");
}
[Fact]
public async Task MarkSending_UpdatesStatus()
{
// Arrange
var digest = CreateDigest("sending-test");
await _repository.UpsertAsync(digest);
// Act
var result = await _repository.MarkSendingAsync(_tenantId, digest.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, digest.Id);
// Assert
result.Should().BeTrue();
fetched!.Status.Should().Be(DigestStatus.Sending);
}
[Fact]
public async Task MarkSent_UpdatesStatusAndSentAt()
{
// Arrange
var digest = CreateDigest("sent-test");
await _repository.UpsertAsync(digest);
await _repository.MarkSendingAsync(_tenantId, digest.Id);
// Act
var result = await _repository.MarkSentAsync(_tenantId, digest.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, digest.Id);
// Assert
result.Should().BeTrue();
fetched!.Status.Should().Be(DigestStatus.Sent);
fetched.SentAt.Should().NotBeNull();
}
[Fact]
public async Task DeleteOld_RemovesOldDigests()
{
// Arrange
var digest = CreateDigest("old-digest");
await _repository.UpsertAsync(digest);
// Act - Delete digests older than future date
var cutoff = DateTimeOffset.UtcNow.AddMinutes(1);
var count = await _repository.DeleteOldAsync(cutoff);
// Assert
count.Should().Be(1);
}
private DigestEntity CreateDigest(string key) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = Guid.NewGuid(),
Recipient = "user@example.com",
DigestKey = key,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
}

View File

@@ -0,0 +1,208 @@
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;
[Collection(NotifyPostgresCollection.Name)]
public sealed class InboxRepositoryTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly InboxRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public InboxRepositoryTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_repository = new InboxRepository(dataSource, NullLogger<InboxRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetById_RoundTripsInboxItem()
{
// Arrange
var userId = Guid.NewGuid();
var inbox = new InboxEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
Title = "New Vulnerability Found",
Body = "Critical vulnerability CVE-2024-1234 detected",
EventType = "vulnerability.found",
ActionUrl = "/scans/123/vulnerabilities"
};
// Act
await _repository.CreateAsync(inbox);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(inbox.Id);
fetched.Title.Should().Be("New Vulnerability Found");
fetched.Read.Should().BeFalse();
}
[Fact]
public async Task GetForUser_ReturnsUserInboxItems()
{
// Arrange
var userId = Guid.NewGuid();
var inbox1 = CreateInbox(userId, "Item 1");
var inbox2 = CreateInbox(userId, "Item 2");
var otherUserInbox = CreateInbox(Guid.NewGuid(), "Other user item");
await _repository.CreateAsync(inbox1);
await _repository.CreateAsync(inbox2);
await _repository.CreateAsync(otherUserInbox);
// Act
var items = await _repository.GetForUserAsync(_tenantId, userId);
// Assert
items.Should().HaveCount(2);
items.Select(i => i.Title).Should().Contain(["Item 1", "Item 2"]);
}
[Fact]
public async Task GetForUser_FiltersUnreadOnly()
{
// Arrange
var userId = Guid.NewGuid();
var unreadItem = CreateInbox(userId, "Unread");
var readItem = CreateInbox(userId, "Read");
await _repository.CreateAsync(unreadItem);
await _repository.CreateAsync(readItem);
await _repository.MarkReadAsync(_tenantId, readItem.Id);
// Act
var unreadItems = await _repository.GetForUserAsync(_tenantId, userId, unreadOnly: true);
// Assert
unreadItems.Should().HaveCount(1);
unreadItems[0].Title.Should().Be("Unread");
}
[Fact]
public async Task GetUnreadCount_ReturnsCorrectCount()
{
// Arrange
var userId = Guid.NewGuid();
await _repository.CreateAsync(CreateInbox(userId, "Unread 1"));
await _repository.CreateAsync(CreateInbox(userId, "Unread 2"));
var readItem = CreateInbox(userId, "Read");
await _repository.CreateAsync(readItem);
await _repository.MarkReadAsync(_tenantId, readItem.Id);
// Act
var count = await _repository.GetUnreadCountAsync(_tenantId, userId);
// Assert
count.Should().Be(2);
}
[Fact]
public async Task MarkRead_UpdatesReadStatus()
{
// Arrange
var userId = Guid.NewGuid();
var inbox = CreateInbox(userId, "To be read");
await _repository.CreateAsync(inbox);
// Act
var result = await _repository.MarkReadAsync(_tenantId, inbox.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id);
// Assert
result.Should().BeTrue();
fetched!.Read.Should().BeTrue();
fetched.ReadAt.Should().NotBeNull();
}
[Fact]
public async Task MarkAllRead_MarksAllUserItemsAsRead()
{
// Arrange
var userId = Guid.NewGuid();
await _repository.CreateAsync(CreateInbox(userId, "Item 1"));
await _repository.CreateAsync(CreateInbox(userId, "Item 2"));
await _repository.CreateAsync(CreateInbox(userId, "Item 3"));
// Act
var count = await _repository.MarkAllReadAsync(_tenantId, userId);
var unreadCount = await _repository.GetUnreadCountAsync(_tenantId, userId);
// Assert
count.Should().Be(3);
unreadCount.Should().Be(0);
}
[Fact]
public async Task Archive_ArchivesItem()
{
// Arrange
var userId = Guid.NewGuid();
var inbox = CreateInbox(userId, "To be archived");
await _repository.CreateAsync(inbox);
// Act
var result = await _repository.ArchiveAsync(_tenantId, inbox.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id);
// Assert
result.Should().BeTrue();
fetched!.Archived.Should().BeTrue();
fetched.ArchivedAt.Should().NotBeNull();
}
[Fact]
public async Task Delete_RemovesItem()
{
// Arrange
var userId = Guid.NewGuid();
var inbox = CreateInbox(userId, "To be deleted");
await _repository.CreateAsync(inbox);
// Act
var result = await _repository.DeleteAsync(_tenantId, inbox.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id);
// Assert
result.Should().BeTrue();
fetched.Should().BeNull();
}
[Fact]
public async Task DeleteOld_RemovesOldItems()
{
// Arrange - We can't easily set CreatedAt in the test, so this tests the API works
var userId = Guid.NewGuid();
await _repository.CreateAsync(CreateInbox(userId, "Recent item"));
// Act - Delete items older than future date (should delete the item)
var cutoff = DateTimeOffset.UtcNow.AddMinutes(1);
var count = await _repository.DeleteOldAsync(cutoff);
// Assert
count.Should().Be(1);
}
private InboxEntity CreateInbox(Guid userId, string title) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
Title = title,
EventType = "test.event"
};
}

View File

@@ -0,0 +1,168 @@
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;
[Collection(NotifyPostgresCollection.Name)]
public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly NotifyAuditRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public NotifyAuditRepositoryTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_repository = new NotifyAuditRepository(dataSource, NullLogger<NotifyAuditRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Create_ReturnsGeneratedId()
{
// Arrange
var audit = new NotifyAuditEntity
{
TenantId = _tenantId,
UserId = Guid.NewGuid(),
Action = "channel.created",
ResourceType = "channel",
ResourceId = Guid.NewGuid().ToString()
};
// Act
var id = await _repository.CreateAsync(audit);
// Assert
id.Should().BeGreaterThan(0);
}
[Fact]
public async Task List_ReturnsAuditEntriesOrderedByCreatedAtDesc()
{
// Arrange
var audit1 = CreateAudit("action1");
var audit2 = CreateAudit("action2");
await _repository.CreateAsync(audit1);
await Task.Delay(10);
await _repository.CreateAsync(audit2);
// Act
var audits = await _repository.ListAsync(_tenantId, limit: 10);
// Assert
audits.Should().HaveCount(2);
audits[0].Action.Should().Be("action2"); // Most recent first
}
[Fact]
public async Task GetByResource_ReturnsResourceAudits()
{
// Arrange
var resourceId = Guid.NewGuid().ToString();
var audit = new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "rule.updated",
ResourceType = "rule",
ResourceId = resourceId
};
await _repository.CreateAsync(audit);
// Act
var audits = await _repository.GetByResourceAsync(_tenantId, "rule", resourceId);
// Assert
audits.Should().HaveCount(1);
audits[0].ResourceId.Should().Be(resourceId);
}
[Fact]
public async Task GetByResource_WithoutResourceId_ReturnsAllOfType()
{
// Arrange
await _repository.CreateAsync(new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "template.created",
ResourceType = "template",
ResourceId = Guid.NewGuid().ToString()
});
await _repository.CreateAsync(new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "template.updated",
ResourceType = "template",
ResourceId = Guid.NewGuid().ToString()
});
// Act
var audits = await _repository.GetByResourceAsync(_tenantId, "template");
// Assert
audits.Should().HaveCount(2);
}
[Fact]
public async Task GetByCorrelationId_ReturnsCorrelatedAudits()
{
// Arrange
var correlationId = Guid.NewGuid().ToString();
var audit1 = new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "step1",
ResourceType = "delivery",
CorrelationId = correlationId
};
var audit2 = new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "step2",
ResourceType = "delivery",
CorrelationId = correlationId
};
await _repository.CreateAsync(audit1);
await _repository.CreateAsync(audit2);
// Act
var audits = await _repository.GetByCorrelationIdAsync(_tenantId, correlationId);
// Assert
audits.Should().HaveCount(2);
audits.Should().AllSatisfy(a => a.CorrelationId.Should().Be(correlationId));
}
[Fact]
public async Task DeleteOld_RemovesOldAudits()
{
// Arrange
await _repository.CreateAsync(CreateAudit("old-action"));
// Act - Delete audits older than future date
var cutoff = DateTimeOffset.UtcNow.AddMinutes(1);
var count = await _repository.DeleteOldAsync(cutoff);
// Assert
count.Should().Be(1);
}
private NotifyAuditEntity CreateAudit(string action) => new()
{
TenantId = _tenantId,
UserId = Guid.NewGuid(),
Action = action,
ResourceType = "test",
ResourceId = Guid.NewGuid().ToString()
};
}

View File

@@ -0,0 +1,197 @@
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;
[Collection(NotifyPostgresCollection.Name)]
public sealed class RuleRepositoryTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly RuleRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public RuleRepositoryTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_repository = new RuleRepository(dataSource, NullLogger<RuleRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetById_RoundTripsRule()
{
// Arrange
var rule = new RuleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "critical-alerts",
Description = "Send critical alerts to ops team",
Enabled = true,
Priority = 100,
EventTypes = ["scan.completed", "vulnerability.found"],
ChannelIds = [Guid.NewGuid()]
};
// Act
await _repository.CreateAsync(rule);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(rule.Id);
fetched.Name.Should().Be("critical-alerts");
fetched.Priority.Should().Be(100);
fetched.EventTypes.Should().Contain(["scan.completed", "vulnerability.found"]);
}
[Fact]
public async Task GetByName_ReturnsCorrectRule()
{
// Arrange
var rule = CreateRule("info-digest");
await _repository.CreateAsync(rule);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "info-digest");
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(rule.Id);
}
[Fact]
public async Task List_ReturnsAllRulesForTenant()
{
// Arrange
var rule1 = CreateRule("rule1");
var rule2 = CreateRule("rule2");
await _repository.CreateAsync(rule1);
await _repository.CreateAsync(rule2);
// Act
var rules = await _repository.ListAsync(_tenantId);
// Assert
rules.Should().HaveCount(2);
rules.Select(r => r.Name).Should().Contain(["rule1", "rule2"]);
}
[Fact]
public async Task List_FiltersByEnabled()
{
// Arrange
var enabledRule = CreateRule("enabled");
var disabledRule = new RuleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "disabled",
Enabled = false,
EventTypes = ["test"]
};
await _repository.CreateAsync(enabledRule);
await _repository.CreateAsync(disabledRule);
// Act
var enabledRules = await _repository.ListAsync(_tenantId, enabled: true);
// Assert
enabledRules.Should().HaveCount(1);
enabledRules[0].Name.Should().Be("enabled");
}
[Fact]
public async Task GetMatchingRules_ReturnsRulesForEventType()
{
// Arrange
var scanRule = new RuleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "scan-rule",
Enabled = true,
EventTypes = ["scan.completed"]
};
var vulnRule = new RuleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "vuln-rule",
Enabled = true,
EventTypes = ["vulnerability.found"]
};
await _repository.CreateAsync(scanRule);
await _repository.CreateAsync(vulnRule);
// Act
var matchingRules = await _repository.GetMatchingRulesAsync(_tenantId, "scan.completed");
// Assert
matchingRules.Should().HaveCount(1);
matchingRules[0].Name.Should().Be("scan-rule");
}
[Fact]
public async Task Update_ModifiesRule()
{
// Arrange
var rule = CreateRule("update-test");
await _repository.CreateAsync(rule);
// Act
var updated = new RuleEntity
{
Id = rule.Id,
TenantId = _tenantId,
Name = "update-test",
Description = "Updated description",
Priority = 200,
Enabled = false,
EventTypes = ["new.event"]
};
var result = await _repository.UpdateAsync(updated);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id);
// Assert
result.Should().BeTrue();
fetched!.Description.Should().Be("Updated description");
fetched.Priority.Should().Be(200);
fetched.Enabled.Should().BeFalse();
}
[Fact]
public async Task Delete_RemovesRule()
{
// Arrange
var rule = CreateRule("delete-test");
await _repository.CreateAsync(rule);
// Act
var result = await _repository.DeleteAsync(_tenantId, rule.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id);
// Assert
result.Should().BeTrue();
fetched.Should().BeNull();
}
private RuleEntity CreateRule(string name) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = name,
Enabled = true,
EventTypes = ["test.event"]
};
}

View File

@@ -0,0 +1,189 @@
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;
[Collection(NotifyPostgresCollection.Name)]
public sealed class TemplateRepositoryTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly TemplateRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public TemplateRepositoryTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_repository = new TemplateRepository(dataSource, NullLogger<TemplateRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAndGetById_RoundTripsTemplate()
{
// Arrange
var template = new TemplateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "scan-completed",
ChannelType = ChannelType.Email,
SubjectTemplate = "Scan Completed: {{imageName}}",
BodyTemplate = "<p>Scan for {{imageName}} completed with {{vulnCount}} vulnerabilities.</p>",
Locale = "en"
};
// Act
await _repository.CreateAsync(template);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(template.Id);
fetched.Name.Should().Be("scan-completed");
fetched.ChannelType.Should().Be(ChannelType.Email);
fetched.SubjectTemplate.Should().Contain("{{imageName}}");
}
[Fact]
public async Task GetByName_ReturnsCorrectTemplate()
{
// Arrange
var template = CreateTemplate("alert-template", ChannelType.Slack);
await _repository.CreateAsync(template);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "alert-template", ChannelType.Slack);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(template.Id);
}
[Fact]
public async Task GetByName_FiltersCorrectlyByLocale()
{
// Arrange
var enTemplate = new TemplateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "localized-template",
ChannelType = ChannelType.Email,
BodyTemplate = "English content",
Locale = "en"
};
var frTemplate = new TemplateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "localized-template",
ChannelType = ChannelType.Email,
BodyTemplate = "Contenu français",
Locale = "fr"
};
await _repository.CreateAsync(enTemplate);
await _repository.CreateAsync(frTemplate);
// Act
var frFetched = await _repository.GetByNameAsync(_tenantId, "localized-template", ChannelType.Email, "fr");
// Assert
frFetched.Should().NotBeNull();
frFetched!.BodyTemplate.Should().Contain("français");
}
[Fact]
public async Task List_ReturnsAllTemplatesForTenant()
{
// Arrange
var template1 = CreateTemplate("template1", ChannelType.Email);
var template2 = CreateTemplate("template2", ChannelType.Slack);
await _repository.CreateAsync(template1);
await _repository.CreateAsync(template2);
// Act
var templates = await _repository.ListAsync(_tenantId);
// Assert
templates.Should().HaveCount(2);
templates.Select(t => t.Name).Should().Contain(["template1", "template2"]);
}
[Fact]
public async Task List_FiltersByChannelType()
{
// Arrange
var emailTemplate = CreateTemplate("email", ChannelType.Email);
var slackTemplate = CreateTemplate("slack", ChannelType.Slack);
await _repository.CreateAsync(emailTemplate);
await _repository.CreateAsync(slackTemplate);
// Act
var emailTemplates = await _repository.ListAsync(_tenantId, channelType: ChannelType.Email);
// Assert
emailTemplates.Should().HaveCount(1);
emailTemplates[0].Name.Should().Be("email");
}
[Fact]
public async Task Update_ModifiesTemplate()
{
// Arrange
var template = CreateTemplate("update-test", ChannelType.Email);
await _repository.CreateAsync(template);
// Act
var updated = new TemplateEntity
{
Id = template.Id,
TenantId = _tenantId,
Name = "update-test",
ChannelType = ChannelType.Email,
SubjectTemplate = "Updated Subject",
BodyTemplate = "Updated body content"
};
var result = await _repository.UpdateAsync(updated);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id);
// Assert
result.Should().BeTrue();
fetched!.SubjectTemplate.Should().Be("Updated Subject");
fetched.BodyTemplate.Should().Be("Updated body content");
}
[Fact]
public async Task Delete_RemovesTemplate()
{
// Arrange
var template = CreateTemplate("delete-test", ChannelType.Email);
await _repository.CreateAsync(template);
// Act
var result = await _repository.DeleteAsync(_tenantId, template.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id);
// Assert
result.Should().BeTrue();
fetched.Should().BeNull();
}
private TemplateEntity CreateTemplate(string name, ChannelType type) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = name,
ChannelType = type,
BodyTemplate = "Default template body"
};
}