using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Notify.Models; using StellaOps.Notifier.Worker.Templates; using Xunit; namespace StellaOps.Notifier.Tests.Templates; public sealed class NotifyTemplateServiceTests { private readonly InMemoryTemplateRepository _templateRepository; private readonly InMemoryAuditRepository _auditRepository; private readonly NotifyTemplateService _service; public NotifyTemplateServiceTests() { _templateRepository = new InMemoryTemplateRepository(); _auditRepository = new InMemoryAuditRepository(); _service = new NotifyTemplateService( _templateRepository, _auditRepository, NullLogger.Instance); } [Fact] public async Task ResolveAsync_ExactLocaleMatch_ReturnsTemplate() { // Arrange var template = CreateTemplate("tmpl-001", "pack.approval", "en-us"); await _templateRepository.UpsertAsync(template); // Act var result = await _service.ResolveAsync( "test-tenant", "pack.approval", NotifyChannelType.Webhook, "en-US"); // Assert Assert.NotNull(result); Assert.Equal("tmpl-001", result.TemplateId); } [Fact] public async Task ResolveAsync_FallbackToLanguageOnly_ReturnsTemplate() { // Arrange var template = CreateTemplate("tmpl-en", "pack.approval", "en"); await _templateRepository.UpsertAsync(template); // Act - request en-GB but only en exists var result = await _service.ResolveAsync( "test-tenant", "pack.approval", NotifyChannelType.Webhook, "en-GB"); // Assert Assert.NotNull(result); Assert.Equal("tmpl-en", result.TemplateId); } [Fact] public async Task ResolveAsync_FallbackToDefault_ReturnsTemplate() { // Arrange var template = CreateTemplate("tmpl-default", "pack.approval", "en-us"); await _templateRepository.UpsertAsync(template); // Act - request de-DE but only en-us exists (default) var result = await _service.ResolveAsync( "test-tenant", "pack.approval", NotifyChannelType.Webhook, "de-DE"); // Assert Assert.NotNull(result); Assert.Equal("tmpl-default", result.TemplateId); } [Fact] public async Task ResolveAsync_NoMatch_ReturnsNull() { // Act var result = await _service.ResolveAsync( "test-tenant", "nonexistent.key", NotifyChannelType.Webhook, "en-US"); // Assert Assert.Null(result); } [Fact] public async Task UpsertAsync_NewTemplate_CreatesAndAudits() { // Arrange var template = CreateTemplate("tmpl-new", "pack.approval", "en-us"); // Act var result = await _service.UpsertAsync(template, "test-actor"); // Assert Assert.True(result.Success); Assert.True(result.IsNew); Assert.Equal("tmpl-new", result.TemplateId); var audit = _auditRepository.Entries.Single(); Assert.Equal("template.created", audit.EventType); Assert.Equal("test-actor", audit.Actor); } [Fact] public async Task UpsertAsync_ExistingTemplate_UpdatesAndAudits() { // Arrange var original = CreateTemplate("tmpl-existing", "pack.approval", "en-us", "Original body"); await _templateRepository.UpsertAsync(original); _auditRepository.Entries.Clear(); var updated = CreateTemplate("tmpl-existing", "pack.approval", "en-us", "Updated body"); // Act var result = await _service.UpsertAsync(updated, "another-actor"); // Assert Assert.True(result.Success); Assert.False(result.IsNew); var audit = _auditRepository.Entries.Single(); Assert.Equal("template.updated", audit.EventType); Assert.Equal("another-actor", audit.Actor); } [Fact] public async Task UpsertAsync_InvalidTemplate_ReturnsError() { // Arrange - template with mismatched braces var template = NotifyTemplate.Create( templateId: "tmpl-invalid", tenantId: "test-tenant", channelType: NotifyChannelType.Webhook, key: "test.key", locale: "en-us", body: "Hello {{name} - missing closing brace"); // Act var result = await _service.UpsertAsync(template, "test-actor"); // Assert Assert.False(result.Success); Assert.Contains("braces", result.Error!, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task DeleteAsync_ExistingTemplate_DeletesAndAudits() { // Arrange var template = CreateTemplate("tmpl-delete", "pack.approval", "en-us"); await _templateRepository.UpsertAsync(template); // Act var deleted = await _service.DeleteAsync("test-tenant", "tmpl-delete", "delete-actor"); // Assert Assert.True(deleted); Assert.Null(await _templateRepository.GetAsync("test-tenant", "tmpl-delete")); var audit = _auditRepository.Entries.Last(); Assert.Equal("template.deleted", audit.EventType); } [Fact] public async Task DeleteAsync_NonexistentTemplate_ReturnsFalse() { // Act var deleted = await _service.DeleteAsync("test-tenant", "nonexistent", "actor"); // Assert Assert.False(deleted); } [Fact] public async Task ListAsync_WithFilters_ReturnsFilteredResults() { // Arrange await _templateRepository.UpsertAsync(CreateTemplate("tmpl-1", "pack.approval", "en-us")); await _templateRepository.UpsertAsync(CreateTemplate("tmpl-2", "pack.approval", "de-de")); await _templateRepository.UpsertAsync(CreateTemplate("tmpl-3", "risk.alert", "en-us")); // Act var results = await _service.ListAsync("test-tenant", new TemplateListOptions { KeyPrefix = "pack.", Locale = "en-us" }); // Assert Assert.Single(results); Assert.Equal("tmpl-1", results[0].TemplateId); } [Fact] public void Validate_ValidTemplate_ReturnsValid() { // Act var result = _service.Validate("Hello {{name}}, your order {{orderId}} is ready."); // Assert Assert.True(result.IsValid); Assert.Empty(result.Errors); } [Fact] public void Validate_MismatchedBraces_ReturnsInvalid() { // Act var result = _service.Validate("Hello {{name}, missing close"); // Assert Assert.False(result.IsValid); Assert.Contains(result.Errors, e => e.Contains("braces")); } [Fact] public void Validate_UnclosedEachBlock_ReturnsInvalid() { // Act var result = _service.Validate("{{#each items}}{{this}}"); // Assert Assert.False(result.IsValid); Assert.Contains(result.Errors, e => e.Contains("#each")); } [Fact] public void Validate_SensitiveVariable_ReturnsWarning() { // Act var result = _service.Validate("Your API key is: {{apiKey}}"); // Assert Assert.True(result.IsValid); Assert.Contains(result.Warnings, w => w.Contains("sensitive")); } [Fact] public void GetRedactionConfig_DefaultMode_ReturnsSafeDefaults() { // Arrange var template = CreateTemplate("tmpl-001", "test.key", "en-us"); // Act var config = _service.GetRedactionConfig(template); // Assert Assert.Equal("safe", config.Mode); Assert.Contains("secret", config.DeniedFields); Assert.Contains("password", config.DeniedFields); } [Fact] public void GetRedactionConfig_ParanoidMode_RequiresAllowlist() { // Arrange var template = NotifyTemplate.Create( templateId: "tmpl-paranoid", tenantId: "test-tenant", channelType: NotifyChannelType.Webhook, key: "test.key", locale: "en-us", body: "Test body", metadata: new Dictionary { ["redaction"] = "paranoid", ["redaction.allow"] = "name,email" }); // Act var config = _service.GetRedactionConfig(template); // Assert Assert.Equal("paranoid", config.Mode); Assert.Contains("name", config.AllowedFields); Assert.Contains("email", config.AllowedFields); } private static NotifyTemplate CreateTemplate( string templateId, string key, string locale, string body = "Test body {{variable}}") { return NotifyTemplate.Create( templateId: templateId, tenantId: "test-tenant", channelType: NotifyChannelType.Webhook, key: key, locale: locale, body: body); } private sealed class InMemoryTemplateRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyTemplateRepository { private readonly Dictionary _templates = new(); public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default) { var key = $"{template.TenantId}:{template.TemplateId}"; _templates[key] = template; return Task.CompletedTask; } public Task GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default) { var key = $"{tenantId}:{templateId}"; return Task.FromResult(_templates.GetValueOrDefault(key)); } public Task> ListAsync(string tenantId, CancellationToken cancellationToken = default) { var result = _templates.Values .Where(t => t.TenantId == tenantId) .ToList(); return Task.FromResult>(result); } public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default) { var key = $"{tenantId}:{templateId}"; _templates.Remove(key); return Task.CompletedTask; } } private sealed class InMemoryAuditRepository : StellaOps.Notify.Storage.Mongo.Repositories.INotifyAuditRepository { public List<(string TenantId, string EventType, string Actor, IReadOnlyDictionary Metadata)> Entries { get; } = []; public Task AppendAsync( string tenantId, string eventType, string actor, IReadOnlyDictionary metadata, CancellationToken cancellationToken) { Entries.Add((tenantId, eventType, actor, metadata)); return Task.CompletedTask; } } }