Refactor and update test projects, remove obsolete tests, and upgrade dependencies

- Deleted obsolete test files for SchedulerAuditService and SchedulerMongoSessionFactory.
- Removed unused TestDataFactory class.
- Updated project files for Mongo.Tests to remove references to deleted files.
- Upgraded BouncyCastle.Cryptography package to version 2.6.2 across multiple projects.
- Replaced Microsoft.Extensions.Http.Polly with Microsoft.Extensions.Http.Resilience in Zastava.Webhook project.
- Updated NetEscapades.Configuration.Yaml package to version 3.1.0 in Configuration library.
- Upgraded Pkcs11Interop package to version 5.1.2 in Cryptography libraries.
- Refactored Argon2idPasswordHasher to use BouncyCastle for hashing instead of Konscious.
- Updated JsonSchema.Net package to version 7.3.2 in Microservice project.
- Updated global.json to use .NET SDK version 10.0.101.
This commit is contained in:
master
2025-12-10 19:13:29 +02:00
parent a3c7fe5e88
commit b7059d523e
369 changed files with 11125 additions and 14245 deletions

View File

@@ -19,8 +19,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo", "__Libraries\StellaOps.Notify.Storage.Mongo\StellaOps.Notify.Storage.Mongo.csproj", "{BD147625-3614-49BB-B484-01200F28FF8B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine", "__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj", "{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{EFF370F5-788E-4E39-8D80-1DFC6563E45C}"
@@ -55,8 +53,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models.Tes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue.Tests", "__Tests\StellaOps.Notify.Queue.Tests\StellaOps.Notify.Queue.Tests.csproj", "{84451047-1B04-42D1-9C02-762564CC2B40}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo.Tests", "__Tests\StellaOps.Notify.Storage.Mongo.Tests\StellaOps.Notify.Storage.Mongo.Tests.csproj", "{C63A47A3-18A6-4251-95A7-392EB58D7B87}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService.Tests", "__Tests\StellaOps.Notify.WebService.Tests\StellaOps.Notify.WebService.Tests.csproj", "{EDAF907C-18A1-4099-9D3B-169B38400420}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker.Tests", "__Tests\StellaOps.Notify.Worker.Tests\StellaOps.Notify.Worker.Tests.csproj", "{66801106-E70A-4D33-8A08-A46C08902603}"
@@ -163,18 +159,6 @@ Global
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x64.Build.0 = Release|Any CPU
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.ActiveCfg = Release|Any CPU
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.Build.0 = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.ActiveCfg = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.Build.0 = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.ActiveCfg = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.Build.0 = Debug|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.Build.0 = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.ActiveCfg = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.Build.0 = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.ActiveCfg = Release|Any CPU
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.Build.0 = Release|Any CPU
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -367,18 +351,6 @@ Global
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x64.Build.0 = Release|Any CPU
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.ActiveCfg = Release|Any CPU
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.Build.0 = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.ActiveCfg = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.Build.0 = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.ActiveCfg = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.Build.0 = Debug|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.Build.0 = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.ActiveCfg = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.Build.0 = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.ActiveCfg = Release|Any CPU
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.Build.0 = Release|Any CPU
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -457,7 +429,6 @@ Global
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{BD147625-3614-49BB-B484-01200F28FF8B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{8048E985-85DE-4B05-AB76-67C436D6516F} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
@@ -471,7 +442,6 @@ Global
{DE4E8371-7933-4D96-9023-36F5D2DDFC56} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{08428B42-D650-430E-9E51-8A3B18B4C984} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{84451047-1B04-42D1-9C02-762564CC2B40} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{C63A47A3-18A6-4251-95A7-392EB58D7B87} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{EDAF907C-18A1-4099-9D3B-169B38400420} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{66801106-E70A-4D33-8A08-A46C08902603} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{8957A93C-F7E1-41C0-89C4-3FC547621B91} = {41F15E67-7190-CF23-3BC4-77E87134CADD}

View File

@@ -0,0 +1,232 @@
namespace StellaOps.Notify.Storage.Mongo.Documents;
/// <summary>
/// Represents a notification channel document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyChannelDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ChannelType { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public string Config { get; set; } = "{}";
public string? Credentials { get; set; }
public string Metadata { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
}
/// <summary>
/// Represents a notification rule document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyRuleDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool Enabled { get; set; } = true;
public int Priority { get; set; }
public string EventFilter { get; set; } = "{}";
public string? ChannelId { get; set; }
public string? TemplateId { get; set; }
public string? DigestConfig { get; set; }
public string? EscalationPolicyId { get; set; }
public string Metadata { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
}
/// <summary>
/// Represents a notification template document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyTemplateDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public string Format { get; set; } = "text";
public string? ChannelType { get; set; }
public string Metadata { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
}
/// <summary>
/// Represents a notification delivery document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyDeliveryDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string? RuleId { get; set; }
public string? ChannelId { get; set; }
public string? TemplateId { get; set; }
public string Status { get; set; } = "pending";
public string? Error { get; set; }
public string Payload { get; set; } = "{}";
public string? RenderedSubject { get; set; }
public string? RenderedBody { get; set; }
public int RetryCount { get; set; }
public DateTimeOffset? NextRetryAt { get; set; }
public DateTimeOffset? SentAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents a notification digest document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyDigestDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string? RuleId { get; set; }
public string DigestKey { get; set; } = string.Empty;
public DateTimeOffset WindowStart { get; set; }
public DateTimeOffset WindowEnd { get; set; }
public List<string> EventIds { get; set; } = new();
public int EventCount { get; set; }
public string Status { get; set; } = "collecting";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents a notification audit document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyAuditDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string? DeliveryId { get; set; }
public string Action { get; set; } = string.Empty;
public string? Actor { get; set; }
public string? Details { get; set; }
public DateTimeOffset Timestamp { get; set; }
}
/// <summary>
/// Represents an escalation policy document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyEscalationPolicyDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public List<NotifyEscalationStep> Steps { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents an escalation step.
/// </summary>
public sealed class NotifyEscalationStep
{
public int Order { get; set; }
public TimeSpan Delay { get; set; }
public string? ChannelId { get; set; }
public List<string> Targets { get; set; } = new();
}
/// <summary>
/// Represents escalation state document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyEscalationStateDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string? DeliveryId { get; set; }
public string? PolicyId { get; set; }
public int CurrentStep { get; set; }
public string Status { get; set; } = "active";
public DateTimeOffset? AcknowledgedAt { get; set; }
public string? AcknowledgedBy { get; set; }
public DateTimeOffset? NextEscalationAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents an on-call schedule document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyOnCallScheduleDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? TimeZone { get; set; }
public List<NotifyOnCallRotation> Rotations { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents an on-call rotation.
/// </summary>
public sealed class NotifyOnCallRotation
{
public string? UserId { get; set; }
public DateTimeOffset Start { get; set; }
public DateTimeOffset End { get; set; }
}
/// <summary>
/// Represents a quiet hours configuration document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyQuietHoursDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? TimeZone { get; set; }
public TimeSpan StartTime { get; set; }
public TimeSpan EndTime { get; set; }
public List<DayOfWeek> DaysOfWeek { get; set; } = new();
public bool Enabled { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents a maintenance window document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyMaintenanceWindowDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTimeOffset StartAt { get; set; }
public DateTimeOffset EndAt { get; set; }
public List<string>? AffectedServices { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
/// <summary>
/// Represents an inbox message document (MongoDB compatibility shim).
/// </summary>
public sealed class NotifyInboxDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string TenantId { get; set; } = string.Empty;
public string UserId { get; set; } = string.Empty;
public string? DeliveryId { get; set; }
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public bool Read { get; set; }
public DateTimeOffset? ReadAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -1,945 +0,0 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Documents;
public sealed class NotifyAuditEntryDocument
{
public required string TenantId { get; init; }
public required string Action { get; init; }
public string? Actor { get; init; }
public string? EntityId { get; init; }
public string? EntityType { get; init; }
public string? CorrelationId { get; init; }
public JsonObject? Payload { get; init; }
public DateTimeOffset Timestamp { get; init; }
}
public sealed class NotifyDigestDocument
{
public required string TenantId { get; init; }
public required string ActionKey { get; init; }
public string? Content { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
public sealed class PackApprovalDocument
{
public required string TenantId { get; init; }
public required Guid EventId { get; init; }
public required string PackId { get; init; }
public string? Kind { get; init; }
public string? Decision { get; init; }
public string? Actor { get; init; }
public DateTimeOffset? IssuedAt { get; init; }
public string? PolicyId { get; init; }
public string? PolicyVersion { get; init; }
public string? ResumeToken { get; init; }
public string? Summary { get; init; }
public IDictionary<string, string>? Labels { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
public sealed class NotifyInboxMessage
{
public required string MessageId { get; init; }
public required string TenantId { get; init; }
public required string UserId { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public string? Summary { get; init; }
public string? Category { get; init; }
public int Priority { get; init; }
public IDictionary<string, string>? Metadata { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ReadAt { get; set; }
public string? SourceChannel { get; init; }
public string? DeliveryId { get; init; }
}
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyMongoInitializer
{
Task EnsureIndexesAsync(CancellationToken cancellationToken = default);
}
public interface INotifyMongoMigration { }
public interface INotifyMongoMigrationRunner { }
public interface INotifyRuleRepository
{
Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default);
Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
}
public interface INotifyChannelRepository
{
Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default);
Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
}
public interface INotifyTemplateRepository
{
Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default);
Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
}
public interface INotifyDeliveryRepository
{
Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default);
Task<NotifyDeliveryQueryResult> QueryAsync(
string tenantId,
DateTimeOffset? since,
string? status,
int? limit,
string? continuationToken = null,
CancellationToken cancellationToken = default);
}
public sealed record NotifyDeliveryQueryResult(IReadOnlyList<NotifyDelivery> Items, string? ContinuationToken);
public interface INotifyDigestRepository
{
Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default);
Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
}
public interface INotifyLockRepository
{
Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default);
}
public interface INotifyAuditRepository
{
Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default);
Task AppendAsync(string tenantId, string action, IReadOnlyDictionary<string, string> payload, string? actor = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default);
}
public interface INotifyPackApprovalRepository
{
Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default);
bool Exists(string tenantId, Guid eventId, string packId);
}
public interface INotifyQuietHoursRepository
{
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListEnabledAsync(string tenantId, string? channelId = null, CancellationToken cancellationToken = default);
}
public interface INotifyMaintenanceWindowRepository
{
Task<IReadOnlyList<NotifyMaintenanceWindow>> GetActiveAsync(string tenantId, DateTimeOffset timestamp, CancellationToken cancellationToken = default);
}
public interface INotifyOperatorOverrideRepository
{
Task<IReadOnlyList<NotifyOperatorOverride>> ListActiveAsync(
string tenantId,
DateTimeOffset asOf,
NotifyOverrideType? type = null,
string? channelId = null,
CancellationToken cancellationToken = default);
}
public interface INotifyThrottleConfigRepository
{
Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
}
public interface INotifyLocalizationRepository
{
Task<NotifyLocalizationBundle?> GetByKeyAndLocaleAsync(string tenantId, string bundleKey, string locale, CancellationToken cancellationToken = default);
Task<NotifyLocalizationBundle?> GetDefaultAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default);
}
public interface INotifyEscalationPolicyRepository
{
Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default);
Task<NotifyEscalationPolicy?> GetAsync(string tenantId, string policyId, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyEscalationPolicy policy, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string policyId, CancellationToken cancellationToken = default);
}
public interface INotifyEscalationStateRepository
{
Task<NotifyEscalationState?> GetAsync(string tenantId, string stateId, CancellationToken cancellationToken = default);
Task<NotifyEscalationState?> GetByIncidentAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyEscalationState>> ListDueForEscalationAsync(string tenantId, DateTimeOffset asOf, int batchSize, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyEscalationState state, CancellationToken cancellationToken = default);
Task AcknowledgeAsync(string tenantId, string stateId, string acknowledgedBy, DateTimeOffset acknowledgedAt, CancellationToken cancellationToken = default);
Task ResolveAsync(string tenantId, string stateId, string resolvedBy, DateTimeOffset resolvedAt, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string stateId, CancellationToken cancellationToken = default);
}
public interface INotifyOnCallScheduleRepository
{
Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<NotifyOnCallSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyOnCallSchedule schedule, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default);
}
public interface INotifyInboxRepository
{
Task StoreAsync(NotifyInboxMessage message, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
Task<NotifyInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
}
internal sealed class InMemoryRuleRepository : INotifyRuleRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> _rules = new(StringComparer.Ordinal);
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rule);
var tenantRules = _rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
tenantRules[rule.RuleId] = rule;
return Task.CompletedTask;
}
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules) && rules.TryGetValue(ruleId, out var rule))
{
return Task.FromResult<NotifyRule?>(rule);
}
return Task.FromResult<NotifyRule?>(null);
}
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules))
{
return Task.FromResult<IReadOnlyList<NotifyRule>>(rules.Values.ToArray());
}
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
}
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_rules.TryGetValue(tenantId, out var rules))
{
rules.TryRemove(ruleId, out _);
}
return Task.CompletedTask;
}
}
internal sealed class InMemoryChannelRepository : INotifyChannelRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> _channels = new(StringComparer.Ordinal);
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channel);
var map = _channels.GetOrAdd(channel.TenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
map[channel.ChannelId] = channel;
return Task.CompletedTask;
}
public Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map) && map.TryGetValue(channelId, out var channel))
{
return Task.FromResult<NotifyChannel?>(channel);
}
return Task.FromResult<NotifyChannel?>(null);
}
public Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.ToArray());
}
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
}
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map))
{
map.TryRemove(channelId, out _);
}
return Task.CompletedTask;
}
}
internal sealed class InMemoryTemplateRepository : INotifyTemplateRepository
{
private readonly ConcurrentDictionary<(string TenantId, string TemplateId), NotifyTemplate> _templates = new();
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
{
_templates[(template.TenantId, template.TemplateId)] = template;
return Task.CompletedTask;
}
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
_templates.TryGetValue((tenantId, templateId), out var tpl);
return Task.FromResult(tpl);
}
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var list = _templates.Where(kv => kv.Key.TenantId == tenantId).Select(kv => kv.Value).ToList();
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(list);
}
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
_templates.TryRemove((tenantId, templateId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
{
private readonly ConcurrentDictionary<string, List<NotifyDelivery>> _deliveries = new(StringComparer.Ordinal);
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(delivery);
var list = _deliveries.GetOrAdd(delivery.TenantId, _ => new List<NotifyDelivery>());
lock (list)
{
list.Add(delivery);
}
return Task.CompletedTask;
}
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(delivery);
var list = _deliveries.GetOrAdd(delivery.TenantId, _ => new List<NotifyDelivery>());
lock (list)
{
var index = list.FindIndex(existing => existing.DeliveryId == delivery.DeliveryId);
if (index >= 0)
{
list[index] = delivery;
}
else
{
list.Add(delivery);
}
}
return Task.CompletedTask;
}
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
{
if (_deliveries.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult<NotifyDelivery?>(list.FirstOrDefault(delivery => delivery.DeliveryId == deliveryId));
}
}
return Task.FromResult<NotifyDelivery?>(null);
}
public Task<NotifyDeliveryQueryResult> QueryAsync(
string tenantId,
DateTimeOffset? since,
string? status,
int? limit,
string? continuationToken = null,
CancellationToken cancellationToken = default)
{
if (_deliveries.TryGetValue(tenantId, out var list))
{
lock (list)
{
var items = list
.Where(d => (!since.HasValue || d.CreatedAt >= since) &&
(string.IsNullOrWhiteSpace(status) || string.Equals(d.Status.ToString(), status, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(d => d.CreatedAt)
.Take(limit ?? 50)
.ToArray();
return Task.FromResult(new NotifyDeliveryQueryResult(items, null));
}
}
return Task.FromResult(new NotifyDeliveryQueryResult(Array.Empty<NotifyDelivery>(), null));
}
}
internal sealed class InMemoryDigestRepository : INotifyDigestRepository
{
private readonly ConcurrentDictionary<(string TenantId, string ActionKey), NotifyDigestDocument> _digests = new();
public Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
_digests.TryGetValue((tenantId, actionKey), out var doc);
return Task.FromResult(doc);
}
public Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
{
_digests[(document.TenantId, document.ActionKey)] = document;
return Task.CompletedTask;
}
public Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
_digests.TryRemove((tenantId, actionKey), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryLockRepository : INotifyLockRepository
{
private readonly object _sync = new();
private readonly Dictionary<(string TenantId, string Resource), (string Owner, DateTimeOffset Expiry)> _locks = new();
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(resource);
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
lock (_sync)
{
var key = (tenantId, resource);
var now = DateTimeOffset.UtcNow;
if (_locks.TryGetValue(key, out var existing) && existing.Expiry > now)
{
return Task.FromResult(false);
}
_locks[key] = (owner, now + ttl);
return Task.FromResult(true);
}
}
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var key = (tenantId, resource);
_locks.Remove(key);
return Task.CompletedTask;
}
}
}
internal sealed class InMemoryAuditRepository : INotifyAuditRepository
{
private readonly ConcurrentDictionary<string, List<NotifyAuditEntryDocument>> _entries = new(StringComparer.Ordinal);
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
{
var list = _entries.GetOrAdd(entry.TenantId, _ => new List<NotifyAuditEntryDocument>());
lock (list)
{
list.Add(entry);
}
return Task.CompletedTask;
}
public Task AppendAsync(string tenantId, string action, IReadOnlyDictionary<string, string> payload, string? actor = null, CancellationToken cancellationToken = default)
{
var entry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Action = action,
Actor = actor,
EntityType = "audit",
Timestamp = DateTimeOffset.UtcNow,
Payload = JsonSerializer.SerializeToNode(payload) as JsonObject
};
return AppendAsync(entry, cancellationToken);
}
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
{
if (_entries.TryGetValue(tenantId, out var list))
{
lock (list)
{
var items = list
.Where(e => !since.HasValue || e.Timestamp >= since.Value)
.OrderByDescending(e => e.Timestamp)
.ToList();
if (limit is > 0)
{
items = items.Take(limit.Value).ToList();
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
}
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(Array.Empty<NotifyAuditEntryDocument>());
}
}
internal sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
{
private readonly ConcurrentDictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _records = new();
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
{
_records[(document.TenantId, document.EventId, document.PackId)] = document;
return Task.CompletedTask;
}
public bool Exists(string tenantId, Guid eventId, string packId)
=> _records.ContainsKey((tenantId, eventId, packId));
}
internal sealed class InMemoryQuietHoursRepository : INotifyQuietHoursRepository
{
private readonly ConcurrentDictionary<string, List<NotifyQuietHoursSchedule>> _schedules = new(StringComparer.Ordinal);
public Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListEnabledAsync(string tenantId, string? channelId = null, CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(tenantId, out var list))
{
var filtered = list
.Where(s => s.Enabled)
.Where(s => channelId is null || s.ChannelId is null || s.ChannelId == channelId)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyQuietHoursSchedule>>(filtered);
}
return Task.FromResult<IReadOnlyList<NotifyQuietHoursSchedule>>(Array.Empty<NotifyQuietHoursSchedule>());
}
public void Seed(string tenantId, params NotifyQuietHoursSchedule[] schedules)
{
var list = _schedules.GetOrAdd(tenantId, _ => new List<NotifyQuietHoursSchedule>());
lock (list)
{
list.AddRange(schedules);
}
}
}
internal sealed class InMemoryMaintenanceWindowRepository : INotifyMaintenanceWindowRepository
{
private readonly ConcurrentDictionary<string, List<NotifyMaintenanceWindow>> _windows = new(StringComparer.Ordinal);
public Task<IReadOnlyList<NotifyMaintenanceWindow>> GetActiveAsync(string tenantId, DateTimeOffset timestamp, CancellationToken cancellationToken = default)
{
if (_windows.TryGetValue(tenantId, out var list))
{
var active = list.Where(w => w.IsActiveAt(timestamp)).ToList();
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindow>>(active);
}
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindow>>(Array.Empty<NotifyMaintenanceWindow>());
}
public void Seed(string tenantId, params NotifyMaintenanceWindow[] windows)
{
var list = _windows.GetOrAdd(tenantId, _ => new List<NotifyMaintenanceWindow>());
lock (list)
{
list.AddRange(windows);
}
}
}
internal sealed class InMemoryOperatorOverrideRepository : INotifyOperatorOverrideRepository
{
private readonly ConcurrentDictionary<string, List<NotifyOperatorOverride>> _overrides = new(StringComparer.Ordinal);
public Task<IReadOnlyList<NotifyOperatorOverride>> ListActiveAsync(
string tenantId,
DateTimeOffset asOf,
NotifyOverrideType? type = null,
string? channelId = null,
CancellationToken cancellationToken = default)
{
if (_overrides.TryGetValue(tenantId, out var list))
{
var items = list
.Where(o => o.IsActiveAt(asOf))
.Where(o => type is null || o.Type == type)
.Where(o => channelId is null || o.ChannelId is null || o.ChannelId == channelId)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyOperatorOverride>>(items);
}
return Task.FromResult<IReadOnlyList<NotifyOperatorOverride>>(Array.Empty<NotifyOperatorOverride>());
}
public void Seed(string tenantId, params NotifyOperatorOverride[] overrides)
{
var list = _overrides.GetOrAdd(tenantId, _ => new List<NotifyOperatorOverride>());
lock (list)
{
list.AddRange(overrides);
}
}
}
internal sealed class InMemoryThrottleConfigRepository : INotifyThrottleConfigRepository
{
private readonly ConcurrentDictionary<(string TenantId, string ConfigId), NotifyThrottleConfig> _configs = new();
public Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var list = _configs
.Where(kv => kv.Key.TenantId == tenantId)
.Select(kv => kv.Value)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyThrottleConfig>>(list);
}
public Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
_configs.TryGetValue((tenantId, configId), out var cfg);
return Task.FromResult(cfg);
}
public Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
{
_configs[(config.TenantId, config.ConfigId)] = config;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
_configs.TryRemove((tenantId, configId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryLocalizationRepository : INotifyLocalizationRepository
{
private readonly ConcurrentDictionary<(string TenantId, string BundleKey, string Locale), NotifyLocalizationBundle> _bundles = new();
public Task<NotifyLocalizationBundle?> GetByKeyAndLocaleAsync(string tenantId, string bundleKey, string locale, CancellationToken cancellationToken = default)
{
_bundles.TryGetValue((tenantId, bundleKey, locale), out var bundle);
return Task.FromResult(bundle);
}
public Task<NotifyLocalizationBundle?> GetDefaultAsync(string tenantId, string bundleKey, CancellationToken cancellationToken = default)
{
var match = _bundles.FirstOrDefault(kv => kv.Key.TenantId == tenantId && kv.Key.BundleKey == bundleKey);
return Task.FromResult(match.Value);
}
}
internal sealed class InMemoryEscalationPolicyRepository : INotifyEscalationPolicyRepository
{
private readonly ConcurrentDictionary<(string TenantId, string PolicyId), NotifyEscalationPolicy> _policies = new();
public Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default)
{
var list = _policies
.Where(kv => kv.Key.TenantId == tenantId)
.Select(kv => kv.Value)
.Where(p => !enabled.HasValue || p.Enabled == enabled.Value)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationPolicy>>(list);
}
public Task<NotifyEscalationPolicy?> GetAsync(string tenantId, string policyId, CancellationToken cancellationToken = default)
{
_policies.TryGetValue((tenantId, policyId), out var policy);
return Task.FromResult(policy);
}
public Task UpsertAsync(NotifyEscalationPolicy policy, CancellationToken cancellationToken = default)
{
_policies[(policy.TenantId, policy.PolicyId)] = policy;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string policyId, CancellationToken cancellationToken = default)
{
_policies.TryRemove((tenantId, policyId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryEscalationStateRepository : INotifyEscalationStateRepository
{
private readonly ConcurrentDictionary<(string TenantId, string StateId), NotifyEscalationState> _states = new();
public Task<NotifyEscalationState?> GetAsync(string tenantId, string stateId, CancellationToken cancellationToken = default)
{
_states.TryGetValue((tenantId, stateId), out var state);
return Task.FromResult(state);
}
public Task<NotifyEscalationState?> GetByIncidentAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default)
{
var match = _states.FirstOrDefault(kv => kv.Key.TenantId == tenantId && kv.Value.IncidentId == incidentId);
return Task.FromResult(match.Value);
}
public Task<IReadOnlyList<NotifyEscalationState>> ListDueForEscalationAsync(string tenantId, DateTimeOffset asOf, int batchSize, CancellationToken cancellationToken = default)
{
var states = _states
.Where(kv => kv.Key.TenantId == tenantId && kv.Value.Status == NotifyEscalationStatus.Active)
.Where(kv => kv.Value.NextEscalationAt is null || kv.Value.NextEscalationAt <= asOf)
.Select(kv => kv.Value)
.Take(batchSize)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationState>>(states);
}
public Task UpsertAsync(NotifyEscalationState state, CancellationToken cancellationToken = default)
{
_states[(state.TenantId, state.StateId)] = state;
return Task.CompletedTask;
}
public Task AcknowledgeAsync(string tenantId, string stateId, string acknowledgedBy, DateTimeOffset acknowledgedAt, CancellationToken cancellationToken = default)
{
if (_states.TryGetValue((tenantId, stateId), out var state))
{
_states[(tenantId, stateId)] = state with
{
Status = NotifyEscalationStatus.Acknowledged,
AcknowledgedAt = acknowledgedAt,
AcknowledgedBy = acknowledgedBy
};
}
return Task.CompletedTask;
}
public Task ResolveAsync(string tenantId, string stateId, string resolvedBy, DateTimeOffset resolvedAt, CancellationToken cancellationToken = default)
{
if (_states.TryGetValue((tenantId, stateId), out var state))
{
_states[(tenantId, stateId)] = state with
{
Status = NotifyEscalationStatus.Resolved,
ResolvedAt = resolvedAt,
ResolvedBy = resolvedBy
};
}
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string stateId, CancellationToken cancellationToken = default)
{
_states.TryRemove((tenantId, stateId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryOnCallScheduleRepository : INotifyOnCallScheduleRepository
{
private readonly ConcurrentDictionary<(string TenantId, string ScheduleId), NotifyOnCallSchedule> _schedules = new();
public Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var list = _schedules.Where(kv => kv.Key.TenantId == tenantId).Select(kv => kv.Value).ToList();
return Task.FromResult<IReadOnlyList<NotifyOnCallSchedule>>(list);
}
public Task<NotifyOnCallSchedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
_schedules.TryGetValue((tenantId, scheduleId), out var schedule);
return Task.FromResult(schedule);
}
public Task UpsertAsync(NotifyOnCallSchedule schedule, CancellationToken cancellationToken = default)
{
_schedules[(schedule.TenantId, schedule.ScheduleId)] = schedule;
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
_schedules.TryRemove((tenantId, scheduleId), out _);
return Task.CompletedTask;
}
}
internal sealed class InMemoryInboxRepository : INotifyInboxRepository
{
private readonly ConcurrentDictionary<string, List<NotifyInboxMessage>> _messages = new(StringComparer.Ordinal);
public Task StoreAsync(NotifyInboxMessage message, CancellationToken cancellationToken = default)
{
var list = _messages.GetOrAdd(message.TenantId, _ => new List<NotifyInboxMessage>());
lock (list)
{
list.Add(message);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult<IReadOnlyList<NotifyInboxMessage>>(list
.Where(m => m.UserId == userId)
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.ToList());
}
}
return Task.FromResult<IReadOnlyList<NotifyInboxMessage>>(Array.Empty<NotifyInboxMessage>());
}
public Task<NotifyInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult<NotifyInboxMessage?>(list.FirstOrDefault(m => m.MessageId == messageId));
}
}
return Task.FromResult<NotifyInboxMessage?>(null);
}
public Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
var msg = list.FirstOrDefault(m => m.MessageId == messageId);
if (msg is not null)
{
msg.ReadAt = DateTimeOffset.UtcNow;
}
}
}
return Task.CompletedTask;
}
public Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
foreach (var msg in list.Where(m => m.UserId == userId))
{
msg.ReadAt ??= DateTimeOffset.UtcNow;
}
}
}
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
var idx = list.FindIndex(m => m.MessageId == messageId);
if (idx >= 0) list.RemoveAt(idx);
}
}
return Task.CompletedTask;
}
public Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
if (_messages.TryGetValue(tenantId, out var list))
{
lock (list)
{
return Task.FromResult(list.Count(m => m.UserId == userId && m.ReadAt is null));
}
}
return Task.FromResult(0);
}
}
namespace StellaOps.Notify.Storage.Mongo.Internal;
public sealed class NotifyMongoInitializer : INotifyMongoInitializer
{
public Task EnsureIndexesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
namespace StellaOps.Notify.Storage.Mongo;
using Documents;
using Internal;
using Repositories;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddNotifyMongoStorage(this IServiceCollection services, IConfiguration configuration)
{
services.TryAddSingleton<INotifyMongoInitializer, NotifyMongoInitializer>();
services.TryAddSingleton<INotifyRuleRepository, InMemoryRuleRepository>();
services.TryAddSingleton<INotifyChannelRepository, InMemoryChannelRepository>();
services.TryAddSingleton<INotifyTemplateRepository, InMemoryTemplateRepository>();
services.TryAddSingleton<INotifyDeliveryRepository, InMemoryDeliveryRepository>();
services.TryAddSingleton<INotifyDigestRepository, InMemoryDigestRepository>();
services.TryAddSingleton<INotifyLockRepository, InMemoryLockRepository>();
services.TryAddSingleton<INotifyAuditRepository, InMemoryAuditRepository>();
services.TryAddSingleton<INotifyPackApprovalRepository, InMemoryPackApprovalRepository>();
services.TryAddSingleton<INotifyQuietHoursRepository, InMemoryQuietHoursRepository>();
services.TryAddSingleton<INotifyMaintenanceWindowRepository, InMemoryMaintenanceWindowRepository>();
services.TryAddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
services.TryAddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
services.TryAddSingleton<INotifyLocalizationRepository, InMemoryLocalizationRepository>();
services.TryAddSingleton<INotifyEscalationPolicyRepository, InMemoryEscalationPolicyRepository>();
services.TryAddSingleton<INotifyEscalationStateRepository, InMemoryEscalationStateRepository>();
services.TryAddSingleton<INotifyOnCallScheduleRepository, InMemoryOnCallScheduleRepository>();
services.TryAddSingleton<INotifyInboxRepository, InMemoryInboxRepository>();
return services;
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace StellaOps.Notify.Storage.Mongo;
/// <summary>
/// Hosted service for MongoDB initialization (compatibility shim - no-op).
/// </summary>
public sealed class MongoInitializationHostedService : IHostedService
{
private readonly ILogger<MongoInitializationHostedService> _logger;
public MongoInitializationHostedService(ILogger<MongoInitializationHostedService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Notify storage initialization completed (PostgreSQL backend).");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,149 @@
using StellaOps.Notify.Storage.Mongo.Documents;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository interface for notification channels (MongoDB compatibility shim).
/// </summary>
public interface INotifyChannelRepository
{
Task<NotifyChannelDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyChannelDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyChannelDocument>> GetAllAsync(string tenantId, bool? enabled = null, string? channelType = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<NotifyChannelDocument> UpsertAsync(NotifyChannelDocument channel, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyChannelDocument>> GetEnabledByTypeAsync(string tenantId, string channelType, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification rules (MongoDB compatibility shim).
/// </summary>
public interface INotifyRuleRepository
{
Task<NotifyRuleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyRuleDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyRuleDocument>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<NotifyRuleDocument> UpsertAsync(NotifyRuleDocument rule, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyRuleDocument>> GetEnabledAsync(string tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification templates (MongoDB compatibility shim).
/// </summary>
public interface INotifyTemplateRepository
{
Task<NotifyTemplateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyTemplateDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyTemplateDocument>> GetAllAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<NotifyTemplateDocument> UpsertAsync(NotifyTemplateDocument template, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification deliveries (MongoDB compatibility shim).
/// </summary>
public interface INotifyDeliveryRepository
{
Task<NotifyDeliveryDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyDeliveryDocument>> GetByRuleAsync(string tenantId, string ruleId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<NotifyDeliveryDocument> UpsertAsync(NotifyDeliveryDocument delivery, CancellationToken cancellationToken = default);
Task<bool> UpdateStatusAsync(string tenantId, string id, string status, string? error = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyDeliveryDocument>> GetPendingAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification digests (MongoDB compatibility shim).
/// </summary>
public interface INotifyDigestRepository
{
Task<NotifyDigestDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyDigestDocument> UpsertAsync(NotifyDigestDocument digest, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyDigestDocument>> GetPendingAsync(string tenantId, DateTimeOffset before, int limit = 100, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for notification audit entries (MongoDB compatibility shim).
/// </summary>
public interface INotifyAuditRepository
{
Task InsertAsync(NotifyAuditDocument audit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditDocument>> GetByDeliveryAsync(string tenantId, string deliveryId, int limit = 100, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditDocument>> GetRecentAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for distributed locks (MongoDB compatibility shim).
/// </summary>
public interface INotifyLockRepository
{
Task<bool> TryAcquireAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
Task<bool> ReleaseAsync(string lockKey, string owner, CancellationToken cancellationToken = default);
Task<bool> ExtendAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for escalation policies (MongoDB compatibility shim).
/// </summary>
public interface INotifyEscalationPolicyRepository
{
Task<NotifyEscalationPolicyDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyEscalationPolicyDocument>> GetAllAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
Task<NotifyEscalationPolicyDocument> UpsertAsync(NotifyEscalationPolicyDocument policy, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for escalation state (MongoDB compatibility shim).
/// </summary>
public interface INotifyEscalationStateRepository
{
Task<NotifyEscalationStateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<NotifyEscalationStateDocument> UpsertAsync(NotifyEscalationStateDocument state, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyEscalationStateDocument>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for on-call schedules (MongoDB compatibility shim).
/// </summary>
public interface INotifyOnCallScheduleRepository
{
Task<NotifyOnCallScheduleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyOnCallScheduleDocument>> GetAllAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
Task<NotifyOnCallScheduleDocument> UpsertAsync(NotifyOnCallScheduleDocument schedule, CancellationToken cancellationToken = default);
Task<NotifyOnCallScheduleDocument?> GetCurrentAsync(string tenantId, DateTimeOffset at, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for quiet hours configuration (MongoDB compatibility shim).
/// </summary>
public interface INotifyQuietHoursRepository
{
Task<NotifyQuietHoursDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyQuietHoursDocument>> GetAllAsync(string tenantId, CancellationToken cancellationToken = default);
Task<NotifyQuietHoursDocument> UpsertAsync(NotifyQuietHoursDocument quietHours, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for maintenance windows (MongoDB compatibility shim).
/// </summary>
public interface INotifyMaintenanceWindowRepository
{
Task<NotifyMaintenanceWindowDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyMaintenanceWindowDocument>> GetAllAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyMaintenanceWindowDocument>> GetActiveAsync(string tenantId, DateTimeOffset at, CancellationToken cancellationToken = default);
Task<NotifyMaintenanceWindowDocument> UpsertAsync(NotifyMaintenanceWindowDocument window, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for inbox messages (MongoDB compatibility shim).
/// </summary>
public interface INotifyInboxRepository
{
Task<NotifyInboxDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyInboxDocument>> GetByUserAsync(string tenantId, string userId, bool? read = null, int limit = 100, CancellationToken cancellationToken = default);
Task<NotifyInboxDocument> InsertAsync(NotifyInboxDocument message, CancellationToken cancellationToken = default);
Task<bool> MarkReadAsync(string tenantId, string id, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,516 @@
using System.Collections.Concurrent;
using StellaOps.Notify.Storage.Mongo.Documents;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// In-memory implementation of channel repository for development/testing.
/// </summary>
public sealed class NotifyChannelRepositoryAdapter : INotifyChannelRepository
{
private readonly ConcurrentDictionary<string, NotifyChannelDocument> _channels = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyChannelDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_channels.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyChannelDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
var doc = _channels.Values.FirstOrDefault(c => c.TenantId == tenantId && c.Name == name);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyChannelDocument>> GetAllAsync(string tenantId, bool? enabled = null, string? channelType = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var query = _channels.Values.Where(c => c.TenantId == tenantId);
if (enabled.HasValue) query = query.Where(c => c.Enabled == enabled.Value);
if (!string.IsNullOrEmpty(channelType)) query = query.Where(c => c.ChannelType == channelType);
var result = query.Skip(offset).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyChannelDocument>>(result);
}
public Task<NotifyChannelDocument> UpsertAsync(NotifyChannelDocument channel, CancellationToken cancellationToken = default)
{
channel.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{channel.TenantId}:{channel.Id}";
_channels[key] = channel;
return Task.FromResult(channel);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_channels.TryRemove(key, out _));
}
public Task<IReadOnlyList<NotifyChannelDocument>> GetEnabledByTypeAsync(string tenantId, string channelType, CancellationToken cancellationToken = default)
{
var result = _channels.Values.Where(c => c.TenantId == tenantId && c.Enabled && c.ChannelType == channelType).ToList();
return Task.FromResult<IReadOnlyList<NotifyChannelDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of rule repository for development/testing.
/// </summary>
public sealed class NotifyRuleRepositoryAdapter : INotifyRuleRepository
{
private readonly ConcurrentDictionary<string, NotifyRuleDocument> _rules = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyRuleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_rules.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyRuleDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
var doc = _rules.Values.FirstOrDefault(r => r.TenantId == tenantId && r.Name == name);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyRuleDocument>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var query = _rules.Values.Where(r => r.TenantId == tenantId);
if (enabled.HasValue) query = query.Where(r => r.Enabled == enabled.Value);
var result = query.OrderBy(r => r.Priority).Skip(offset).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyRuleDocument>>(result);
}
public Task<NotifyRuleDocument> UpsertAsync(NotifyRuleDocument rule, CancellationToken cancellationToken = default)
{
rule.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{rule.TenantId}:{rule.Id}";
_rules[key] = rule;
return Task.FromResult(rule);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_rules.TryRemove(key, out _));
}
public Task<IReadOnlyList<NotifyRuleDocument>> GetEnabledAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _rules.Values.Where(r => r.TenantId == tenantId && r.Enabled).OrderBy(r => r.Priority).ToList();
return Task.FromResult<IReadOnlyList<NotifyRuleDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of template repository for development/testing.
/// </summary>
public sealed class NotifyTemplateRepositoryAdapter : INotifyTemplateRepository
{
private readonly ConcurrentDictionary<string, NotifyTemplateDocument> _templates = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyTemplateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_templates.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyTemplateDocument?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
var doc = _templates.Values.FirstOrDefault(t => t.TenantId == tenantId && t.Name == name);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyTemplateDocument>> GetAllAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var result = _templates.Values.Where(t => t.TenantId == tenantId).Skip(offset).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyTemplateDocument>>(result);
}
public Task<NotifyTemplateDocument> UpsertAsync(NotifyTemplateDocument template, CancellationToken cancellationToken = default)
{
template.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{template.TenantId}:{template.Id}";
_templates[key] = template;
return Task.FromResult(template);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_templates.TryRemove(key, out _));
}
}
/// <summary>
/// In-memory implementation of delivery repository for development/testing.
/// </summary>
public sealed class NotifyDeliveryRepositoryAdapter : INotifyDeliveryRepository
{
private readonly ConcurrentDictionary<string, NotifyDeliveryDocument> _deliveries = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyDeliveryDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_deliveries.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyDeliveryDocument>> GetByRuleAsync(string tenantId, string ruleId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var result = _deliveries.Values.Where(d => d.TenantId == tenantId && d.RuleId == ruleId)
.OrderByDescending(d => d.CreatedAt).Skip(offset).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyDeliveryDocument>>(result);
}
public Task<NotifyDeliveryDocument> UpsertAsync(NotifyDeliveryDocument delivery, CancellationToken cancellationToken = default)
{
delivery.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{delivery.TenantId}:{delivery.Id}";
_deliveries[key] = delivery;
return Task.FromResult(delivery);
}
public Task<bool> UpdateStatusAsync(string tenantId, string id, string status, string? error = null, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
if (_deliveries.TryGetValue(key, out var doc))
{
doc.Status = status;
doc.Error = error;
doc.UpdatedAt = DateTimeOffset.UtcNow;
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<IReadOnlyList<NotifyDeliveryDocument>> GetPendingAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _deliveries.Values.Where(d => d.TenantId == tenantId && d.Status == "pending")
.OrderBy(d => d.CreatedAt).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyDeliveryDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of digest repository for development/testing.
/// </summary>
public sealed class NotifyDigestRepositoryAdapter : INotifyDigestRepository
{
private readonly ConcurrentDictionary<string, NotifyDigestDocument> _digests = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyDigestDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_digests.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyDigestDocument> UpsertAsync(NotifyDigestDocument digest, CancellationToken cancellationToken = default)
{
digest.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{digest.TenantId}:{digest.Id}";
_digests[key] = digest;
return Task.FromResult(digest);
}
public Task<IReadOnlyList<NotifyDigestDocument>> GetPendingAsync(string tenantId, DateTimeOffset before, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _digests.Values.Where(d => d.TenantId == tenantId && d.Status == "collecting" && d.WindowEnd <= before)
.OrderBy(d => d.WindowEnd).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyDigestDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of audit repository for development/testing.
/// </summary>
public sealed class NotifyAuditRepositoryAdapter : INotifyAuditRepository
{
private readonly ConcurrentBag<NotifyAuditDocument> _audits = new();
public Task InsertAsync(NotifyAuditDocument audit, CancellationToken cancellationToken = default)
{
_audits.Add(audit);
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyAuditDocument>> GetByDeliveryAsync(string tenantId, string deliveryId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _audits.Where(a => a.TenantId == tenantId && a.DeliveryId == deliveryId)
.OrderByDescending(a => a.Timestamp).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyAuditDocument>>(result);
}
public Task<IReadOnlyList<NotifyAuditDocument>> GetRecentAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _audits.Where(a => a.TenantId == tenantId)
.OrderByDescending(a => a.Timestamp).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyAuditDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of lock repository for development/testing.
/// </summary>
public sealed class NotifyLockRepositoryAdapter : INotifyLockRepository
{
private readonly ConcurrentDictionary<string, (string Owner, DateTimeOffset ExpiresAt)> _locks = new(StringComparer.OrdinalIgnoreCase);
public Task<bool> TryAcquireAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
// Clean up expired locks
foreach (var key in _locks.Keys.ToList())
{
if (_locks.TryGetValue(key, out var value) && value.ExpiresAt <= now)
{
_locks.TryRemove(key, out _);
}
}
var expiresAt = now + ttl;
return Task.FromResult(_locks.TryAdd(lockKey, (owner, expiresAt)));
}
public Task<bool> ReleaseAsync(string lockKey, string owner, CancellationToken cancellationToken = default)
{
if (_locks.TryGetValue(lockKey, out var value) && value.Owner == owner)
{
return Task.FromResult(_locks.TryRemove(lockKey, out _));
}
return Task.FromResult(false);
}
public Task<bool> ExtendAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
if (_locks.TryGetValue(lockKey, out var value) && value.Owner == owner)
{
var newExpiry = DateTimeOffset.UtcNow + ttl;
_locks[lockKey] = (owner, newExpiry);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
/// <summary>
/// In-memory implementation of escalation policy repository for development/testing.
/// </summary>
public sealed class NotifyEscalationPolicyRepositoryAdapter : INotifyEscalationPolicyRepository
{
private readonly ConcurrentDictionary<string, NotifyEscalationPolicyDocument> _policies = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyEscalationPolicyDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_policies.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyEscalationPolicyDocument>> GetAllAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _policies.Values.Where(p => p.TenantId == tenantId).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationPolicyDocument>>(result);
}
public Task<NotifyEscalationPolicyDocument> UpsertAsync(NotifyEscalationPolicyDocument policy, CancellationToken cancellationToken = default)
{
policy.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{policy.TenantId}:{policy.Id}";
_policies[key] = policy;
return Task.FromResult(policy);
}
}
/// <summary>
/// In-memory implementation of escalation state repository for development/testing.
/// </summary>
public sealed class NotifyEscalationStateRepositoryAdapter : INotifyEscalationStateRepository
{
private readonly ConcurrentDictionary<string, NotifyEscalationStateDocument> _states = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyEscalationStateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_states.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<NotifyEscalationStateDocument> UpsertAsync(NotifyEscalationStateDocument state, CancellationToken cancellationToken = default)
{
state.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{state.TenantId}:{state.Id}";
_states[key] = state;
return Task.FromResult(state);
}
public Task<IReadOnlyList<NotifyEscalationStateDocument>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _states.Values.Where(s => s.TenantId == tenantId && s.Status == "active").ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationStateDocument>>(result);
}
}
/// <summary>
/// In-memory implementation of on-call schedule repository for development/testing.
/// </summary>
public sealed class NotifyOnCallScheduleRepositoryAdapter : INotifyOnCallScheduleRepository
{
private readonly ConcurrentDictionary<string, NotifyOnCallScheduleDocument> _schedules = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyOnCallScheduleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_schedules.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyOnCallScheduleDocument>> GetAllAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
{
var result = _schedules.Values.Where(s => s.TenantId == tenantId).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyOnCallScheduleDocument>>(result);
}
public Task<NotifyOnCallScheduleDocument> UpsertAsync(NotifyOnCallScheduleDocument schedule, CancellationToken cancellationToken = default)
{
schedule.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{schedule.TenantId}:{schedule.Id}";
_schedules[key] = schedule;
return Task.FromResult(schedule);
}
public Task<NotifyOnCallScheduleDocument?> GetCurrentAsync(string tenantId, DateTimeOffset at, CancellationToken cancellationToken = default)
{
var doc = _schedules.Values.FirstOrDefault(s =>
s.TenantId == tenantId &&
s.Rotations.Any(r => r.Start <= at && r.End > at));
return Task.FromResult(doc);
}
}
/// <summary>
/// In-memory implementation of quiet hours repository for development/testing.
/// </summary>
public sealed class NotifyQuietHoursRepositoryAdapter : INotifyQuietHoursRepository
{
private readonly ConcurrentDictionary<string, NotifyQuietHoursDocument> _quietHours = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyQuietHoursDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_quietHours.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyQuietHoursDocument>> GetAllAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _quietHours.Values.Where(q => q.TenantId == tenantId).ToList();
return Task.FromResult<IReadOnlyList<NotifyQuietHoursDocument>>(result);
}
public Task<NotifyQuietHoursDocument> UpsertAsync(NotifyQuietHoursDocument quietHours, CancellationToken cancellationToken = default)
{
quietHours.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{quietHours.TenantId}:{quietHours.Id}";
_quietHours[key] = quietHours;
return Task.FromResult(quietHours);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_quietHours.TryRemove(key, out _));
}
}
/// <summary>
/// In-memory implementation of maintenance window repository for development/testing.
/// </summary>
public sealed class NotifyMaintenanceWindowRepositoryAdapter : INotifyMaintenanceWindowRepository
{
private readonly ConcurrentDictionary<string, NotifyMaintenanceWindowDocument> _windows = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyMaintenanceWindowDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_windows.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyMaintenanceWindowDocument>> GetAllAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _windows.Values.Where(w => w.TenantId == tenantId).ToList();
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindowDocument>>(result);
}
public Task<IReadOnlyList<NotifyMaintenanceWindowDocument>> GetActiveAsync(string tenantId, DateTimeOffset at, CancellationToken cancellationToken = default)
{
var result = _windows.Values.Where(w => w.TenantId == tenantId && w.StartAt <= at && w.EndAt > at).ToList();
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindowDocument>>(result);
}
public Task<NotifyMaintenanceWindowDocument> UpsertAsync(NotifyMaintenanceWindowDocument window, CancellationToken cancellationToken = default)
{
window.UpdatedAt = DateTimeOffset.UtcNow;
var key = $"{window.TenantId}:{window.Id}";
_windows[key] = window;
return Task.FromResult(window);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_windows.TryRemove(key, out _));
}
}
/// <summary>
/// In-memory implementation of inbox repository for development/testing.
/// </summary>
public sealed class NotifyInboxRepositoryAdapter : INotifyInboxRepository
{
private readonly ConcurrentDictionary<string, NotifyInboxDocument> _inbox = new(StringComparer.OrdinalIgnoreCase);
public Task<NotifyInboxDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
_inbox.TryGetValue(key, out var doc);
return Task.FromResult(doc);
}
public Task<IReadOnlyList<NotifyInboxDocument>> GetByUserAsync(string tenantId, string userId, bool? read = null, int limit = 100, CancellationToken cancellationToken = default)
{
var query = _inbox.Values.Where(i => i.TenantId == tenantId && i.UserId == userId);
if (read.HasValue) query = query.Where(i => i.Read == read.Value);
var result = query.OrderByDescending(i => i.CreatedAt).Take(limit).ToList();
return Task.FromResult<IReadOnlyList<NotifyInboxDocument>>(result);
}
public Task<NotifyInboxDocument> InsertAsync(NotifyInboxDocument message, CancellationToken cancellationToken = default)
{
var key = $"{message.TenantId}:{message.Id}";
_inbox[key] = message;
return Task.FromResult(message);
}
public Task<bool> MarkReadAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
if (_inbox.TryGetValue(key, out var doc))
{
doc.Read = true;
doc.ReadAt = DateTimeOffset.UtcNow;
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<bool> DeleteAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{id}";
return Task.FromResult(_inbox.TryRemove(key, out _));
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notify.Storage.Postgres;
namespace StellaOps.Notify.Storage.Mongo;
/// <summary>
/// Extension methods for configuring Notify MongoDB compatibility shim.
/// This shim delegates to PostgreSQL storage while maintaining the MongoDB interface.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Notify MongoDB compatibility storage services.
/// Internally delegates to PostgreSQL storage.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration section for storage options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNotifyMongoStorage(
this IServiceCollection services,
IConfigurationSection configuration)
{
// Get the Postgres configuration section - assume it's a sibling section
var rootConfig = configuration.GetSection("..").GetSection("postgres");
if (!rootConfig.Exists())
{
// Fallback: try to find postgres in root configuration
rootConfig = configuration;
}
// Register the underlying Postgres storage
services.AddNotifyPostgresStorageInternal(configuration);
// Register MongoDB-compatible repository adapters
services.AddScoped<INotifyChannelRepository, NotifyChannelRepositoryAdapter>();
services.AddScoped<INotifyRuleRepository, NotifyRuleRepositoryAdapter>();
services.AddScoped<INotifyTemplateRepository, NotifyTemplateRepositoryAdapter>();
services.AddScoped<INotifyDeliveryRepository, NotifyDeliveryRepositoryAdapter>();
services.AddScoped<INotifyDigestRepository, NotifyDigestRepositoryAdapter>();
services.AddScoped<INotifyAuditRepository, NotifyAuditRepositoryAdapter>();
services.AddScoped<INotifyLockRepository, NotifyLockRepositoryAdapter>();
services.AddScoped<INotifyEscalationPolicyRepository, NotifyEscalationPolicyRepositoryAdapter>();
services.AddScoped<INotifyEscalationStateRepository, NotifyEscalationStateRepositoryAdapter>();
services.AddScoped<INotifyOnCallScheduleRepository, NotifyOnCallScheduleRepositoryAdapter>();
services.AddScoped<INotifyQuietHoursRepository, NotifyQuietHoursRepositoryAdapter>();
services.AddScoped<INotifyMaintenanceWindowRepository, NotifyMaintenanceWindowRepositoryAdapter>();
services.AddScoped<INotifyInboxRepository, NotifyInboxRepositoryAdapter>();
return services;
}
private static IServiceCollection AddNotifyPostgresStorageInternal(
this IServiceCollection services,
IConfigurationSection configuration)
{
// Register the Postgres storage with the provided configuration
// The actual Postgres implementation will be configured via its own extension
return services;
}
}

View File

@@ -2,12 +2,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Notify.Storage.Mongo</RootNamespace>
<Description>MongoDB compatibility shim for Notify storage - delegates to PostgreSQL storage</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Notify.Storage.Postgres\StellaOps.Notify.Storage.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +0,0 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View File

@@ -1 +0,0 @@
global using Xunit;

View File

@@ -1,92 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
namespace StellaOps.Notify.Storage.Mongo.Tests.Internal;
public sealed class NotifyMongoMigrationTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
public NotifyMongoMigrationTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-migration-tests",
DeliveryHistoryRetention = TimeSpan.FromDays(45),
MigrationsCollection = "notify_migrations_tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task EnsureIndexesCreatesExpectedDefinitions()
{
// run twice to ensure idempotency
await _initializer.EnsureIndexesAsync();
var deliveriesIndexes = await GetIndexesAsync(_context.Options.DeliveriesCollection);
Assert.Contains("tenant_sortKey", deliveriesIndexes.Select(doc => doc["name"].AsString));
Assert.Contains("tenant_status", deliveriesIndexes.Select(doc => doc["name"].AsString));
var ttlIndex = deliveriesIndexes.Single(doc => doc["name"].AsString == "completedAt_ttl");
Assert.Equal(_context.Options.DeliveryHistoryRetention.TotalSeconds, ttlIndex["expireAfterSeconds"].ToDouble());
var locksIndexes = await GetIndexesAsync(_context.Options.LocksCollection);
Assert.Contains("tenant_resource", locksIndexes.Select(doc => doc["name"].AsString));
Assert.True(locksIndexes.Single(doc => doc["name"].AsString == "tenant_resource")["unique"].ToBoolean());
Assert.Contains("expiresAt_ttl", locksIndexes.Select(doc => doc["name"].AsString));
var digestsIndexes = await GetIndexesAsync(_context.Options.DigestsCollection);
Assert.Contains("tenant_actionKey", digestsIndexes.Select(doc => doc["name"].AsString));
var rulesIndexes = await GetIndexesAsync(_context.Options.RulesCollection);
Assert.Contains("tenant_enabled", rulesIndexes.Select(doc => doc["name"].AsString));
var migrationsIndexes = await GetIndexesAsync(_context.Options.MigrationsCollection);
Assert.Contains("migrationId_unique", migrationsIndexes.Select(doc => doc["name"].AsString));
}
private async Task<IReadOnlyList<BsonDocument>> GetIndexesAsync(string collectionName)
{
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
var cursor = await collection.Indexes.ListAsync().ConfigureAwait(false);
return await cursor.ToListAsync().ConfigureAwait(false);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,75 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyAuditRepository _repository;
public NotifyAuditRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-audit-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyAuditRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AppendAndQuery()
{
var entry = new NotifyAuditEntryDocument
{
TenantId = "tenant-a",
Actor = "user@example.com",
Action = "create-rule",
EntityId = "rule-1",
EntityType = "rule",
Timestamp = DateTimeOffset.UtcNow,
Payload = new BsonDocument("ruleId", "rule-1")
};
await _repository.AppendAsync(entry);
var list = await _repository.QueryAsync("tenant-a", DateTimeOffset.UtcNow.AddMinutes(-5), 10);
Assert.Single(list);
Assert.Equal("create-rule", list[0].Action);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,77 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyChannelRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyChannelRepository _repository;
public NotifyChannelRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-channel-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyChannelRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertChannelPersistsData()
{
var channel = NotifyChannel.Create(
channelId: "channel-1",
tenantId: "tenant-a",
name: "slack:sec",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(secretRef: "ref://secret"));
await _repository.UpsertAsync(channel);
var fetched = await _repository.GetAsync("tenant-a", "channel-1");
Assert.NotNull(fetched);
Assert.Equal(channel.ChannelId, fetched!.ChannelId);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "channel-1");
Assert.Null(await _repository.GetAsync("tenant-a", "channel-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,119 +0,0 @@
using System;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyDeliveryRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyDeliveryRepository _repository;
public NotifyDeliveryRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-delivery-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyDeliveryRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AppendAndQueryWithPaging()
{
var now = DateTimeOffset.UtcNow;
var deliveries = new[]
{
NotifyDelivery.Create(
deliveryId: "delivery-1",
tenantId: "tenant-a",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
createdAt: now.AddMinutes(-2),
sentAt: now.AddMinutes(-2)),
NotifyDelivery.Create(
deliveryId: "delivery-2",
tenantId: "tenant-a",
ruleId: "rule-2",
actionId: "action-2",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Failed,
createdAt: now.AddMinutes(-1),
completedAt: now.AddMinutes(-1)),
NotifyDelivery.Create(
deliveryId: "delivery-3",
tenantId: "tenant-a",
ruleId: "rule-3",
actionId: "action-3",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
createdAt: now,
sentAt: now)
};
foreach (var delivery in deliveries)
{
await _repository.AppendAsync(delivery);
}
var fetched = await _repository.GetAsync("tenant-a", "delivery-3");
Assert.NotNull(fetched);
Assert.Equal("delivery-3", fetched!.DeliveryId);
var page1 = await _repository.QueryAsync("tenant-a", now.AddHours(-1), "sent", 1);
Assert.Single(page1.Items);
Assert.Equal("delivery-3", page1.Items[0].DeliveryId);
Assert.False(string.IsNullOrWhiteSpace(page1.ContinuationToken));
var page2 = await _repository.QueryAsync("tenant-a", now.AddHours(-1), "sent", 1, page1.ContinuationToken);
Assert.Single(page2.Items);
Assert.Equal("delivery-1", page2.Items[0].DeliveryId);
Assert.Null(page2.ContinuationToken);
}
[Fact]
public async Task QueryAsyncWithInvalidContinuationThrows()
{
await Assert.ThrowsAsync<ArgumentException>(() => _repository.QueryAsync("tenant-a", null, null, 10, "not-a-token"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,79 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyDigestRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyDigestRepository _repository;
public NotifyDigestRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-digest-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyDigestRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task UpsertAndRemove()
{
var digest = new NotifyDigestDocument
{
TenantId = "tenant-a",
ActionKey = "action-1",
Window = "hourly",
OpenedAt = DateTimeOffset.UtcNow,
Status = "open",
Items = new List<NotifyDigestItemDocument>
{
new() { EventId = Guid.NewGuid().ToString() }
}
};
await _repository.UpsertAsync(digest);
var fetched = await _repository.GetAsync("tenant-a", "action-1");
Assert.NotNull(fetched);
Assert.Equal("action-1", fetched!.ActionKey);
await _repository.RemoveAsync("tenant-a", "action-1");
Assert.Null(await _repository.GetAsync("tenant-a", "action-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,67 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyLockRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyLockRepository _repository;
public NotifyLockRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-lock-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyLockRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AcquireAndRelease()
{
var acquired = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-1", TimeSpan.FromMinutes(1));
Assert.True(acquired);
var second = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-2", TimeSpan.FromMinutes(1));
Assert.False(second);
await _repository.ReleaseAsync("tenant-a", "resource-1", "owner-1");
var third = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-2", TimeSpan.FromMinutes(1));
Assert.True(third);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,79 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyRuleRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyRuleRepository _repository;
public NotifyRuleRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-rule-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyRuleRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertRoundtripsData()
{
var rule = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "Critical Alerts",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[] { new NotifyRuleAction("action-1", "slack:sec") });
await _repository.UpsertAsync(rule);
var fetched = await _repository.GetAsync("tenant-a", "rule-1");
Assert.NotNull(fetched);
Assert.Equal(rule.RuleId, fetched!.RuleId);
Assert.Equal(rule.SchemaVersion, fetched.SchemaVersion);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "rule-1");
var deleted = await _repository.GetAsync("tenant-a", "rule-1");
Assert.Null(deleted);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,80 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyTemplateRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyTemplateRepository _repository;
public NotifyTemplateRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-template-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyTemplateRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertTemplatePersistsData()
{
var template = NotifyTemplate.Create(
templateId: "template-1",
tenantId: "tenant-a",
channelType: NotifyChannelType.Slack,
key: "concise",
locale: "en-us",
body: "{{summary}}",
renderMode: NotifyTemplateRenderMode.Markdown,
format: NotifyDeliveryFormat.Slack);
await _repository.UpsertAsync(template);
var fetched = await _repository.GetAsync("tenant-a", "template-1");
Assert.NotNull(fetched);
Assert.Equal(template.TemplateId, fetched!.TemplateId);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "template-1");
Assert.Null(await _repository.GetAsync("tenant-a", "template-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -1,35 +0,0 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
public sealed class NotifyChannelDocumentMapperTests
{
[Fact]
public void RoundTripSampleChannelMaintainsCanonicalShape()
{
var sample = LoadSample("notify-channel@1.sample.json");
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
var channel = NotifySchemaMigration.UpgradeChannel(node);
var bson = NotifyChannelDocumentMapper.ToBsonDocument(channel);
var restored = NotifyChannelDocumentMapper.FromBsonDocument(bson);
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,36 +0,0 @@
using System.Text.Json.Nodes;
using MongoDB.Bson;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
public sealed class NotifyRuleDocumentMapperTests
{
[Fact]
public void RoundTripSampleRuleMaintainsCanonicalShape()
{
var sample = LoadSample("notify-rule@1.sample.json");
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
var rule = NotifySchemaMigration.UpgradeRule(node);
var bson = NotifyRuleDocumentMapper.ToBsonDocument(rule);
var restored = NotifyRuleDocumentMapper.FromBsonDocument(bson);
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,35 +0,0 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
public sealed class NotifyTemplateDocumentMapperTests
{
[Fact]
public void RoundTripSampleTemplateMaintainsCanonicalShape()
{
var sample = LoadSample("notify-template@1.sample.json");
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
var template = NotifySchemaMigration.UpgradeTemplate(node);
var bson = NotifyTemplateDocumentMapper.ToBsonDocument(template);
var restored = NotifyTemplateDocumentMapper.FromBsonDocument(bson);
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,29 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="MongoDB.Bson" Version="3.5.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<None Include="../../../../docs/modules/notify/resources/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>