extern alias webservice; using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using StellaOps.Notifier.WebService.Contracts; using StellaOps.Notifier.Worker.Storage; using StellaOps.Notify.Models; using WebProgram = webservice::Program; using Xunit; namespace StellaOps.Notifier.Tests.Endpoints; public sealed class NotifyApiEndpointsTests : IClassFixture> { private readonly HttpClient _client; private readonly InMemoryRuleRepository _ruleRepository; private readonly InMemoryTemplateRepository _templateRepository; private readonly WebApplicationFactory _factory; public NotifyApiEndpointsTests(WebApplicationFactory factory) { _ruleRepository = new InMemoryRuleRepository(); _templateRepository = new InMemoryTemplateRepository(); var customFactory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { services.AddSingleton(_ruleRepository); services.AddSingleton(_templateRepository); }); builder.UseSetting("Environment", "Testing"); }); _factory = customFactory; _client = customFactory.CreateClient(); _client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant"); } #region Rules API Tests [Fact] public async Task GetRules_ReturnsEmptyList_WhenNoRules() { // Act var response = await _client.GetAsync("/api/v2/notify/rules", CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var rules = await response.Content.ReadFromJsonAsync>(cancellationToken: CancellationToken.None); Assert.NotNull(rules); Assert.Empty(rules); } [Fact] public async Task CreateRule_ReturnsCreated_WithValidRequest() { // Arrange var request = new RuleCreateRequest { RuleId = "rule-001", Name = "Test Rule", Description = "Test description", Enabled = true, Match = new RuleMatchRequest { EventKinds = ["pack.approval.granted"], Labels = ["env=prod"] }, Actions = [ new RuleActionRequest { ActionId = "action-001", Channel = "slack:alerts", Template = "tmpl-slack-001" } ] }; // Act var response = await _client.PostAsJsonAsync("/api/v2/notify/rules", request, cancellationToken: CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); var rule = await response.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); Assert.NotNull(rule); Assert.Equal("rule-001", rule.RuleId); Assert.Equal("Test Rule", rule.Name); } [Fact] public async Task GetRule_ReturnsRule_WhenExists() { // Arrange var rule = NotifyRule.Create( ruleId: "rule-get-001", tenantId: "test-tenant", name: "Existing Rule", match: NotifyRuleMatch.Create(eventKinds: ["test.event"]), actions: new[] { NotifyRuleAction.Create( actionId: "action-001", channel: "slack:alerts", template: "tmpl-001") }); await _ruleRepository.UpsertAsync(rule, CancellationToken.None); // Act var response = await _client.GetAsync("/api/v2/notify/rules/rule-get-001", CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); Assert.NotNull(result); Assert.Equal("rule-get-001", result.RuleId); } [Fact] public async Task GetRule_ReturnsNotFound_WhenNotExists() { // Act var response = await _client.GetAsync("/api/v2/notify/rules/nonexistent", CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task DeleteRule_ReturnsNoContent_WhenExists() { // Arrange var rule = NotifyRule.Create( ruleId: "rule-delete-001", tenantId: "test-tenant", name: "Delete Me", match: NotifyRuleMatch.Create(), actions: new[] { NotifyRuleAction.Create( actionId: "action-001", channel: "slack:alerts", template: "tmpl-001") }); await _ruleRepository.UpsertAsync(rule, CancellationToken.None); // Act var response = await _client.DeleteAsync("/api/v2/notify/rules/rule-delete-001", CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } #endregion #region Templates API Tests [Fact] public async Task GetTemplates_ReturnsEmptyList_WhenNoTemplates() { // Act var response = await _client.GetAsync("/api/v2/notify/templates", CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var templates = await response.Content.ReadFromJsonAsync>(cancellationToken: CancellationToken.None); Assert.NotNull(templates); } [Fact] public async Task PreviewTemplate_ReturnsRenderedContent() { // Arrange var request = new TemplatePreviewRequest { TemplateBody = "Hello {{name}}, you have {{count}} messages.", SamplePayload = JsonSerializer.SerializeToNode(new { name = "World", count = 5 }) as System.Text.Json.Nodes.JsonObject }; // Act var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/preview", request, cancellationToken: CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var preview = await response.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); Assert.NotNull(preview); Assert.Contains("Hello World", preview.RenderedBody); Assert.Contains("5", preview.RenderedBody); } [Fact] public async Task ValidateTemplate_ReturnsValid_ForCorrectTemplate() { // Arrange var request = new TemplatePreviewRequest { TemplateBody = "Hello {{name}}!" }; // Act var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/validate", request, cancellationToken: CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); Assert.True(result.GetProperty("isValid").GetBoolean()); } [Fact] public async Task ValidateTemplate_ReturnsInvalid_ForBrokenTemplate() { // Arrange var request = new TemplatePreviewRequest { TemplateBody = "Hello {{name} - missing closing brace" }; // Act var response = await _client.PostAsJsonAsync("/api/v2/notify/templates/validate", request, cancellationToken: CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); Assert.False(result.GetProperty("isValid").GetBoolean()); } #endregion #region Incidents API Tests [Fact] public async Task GetIncidents_ReturnsIncidentList() { // Act var response = await _client.GetAsync("/api/v2/notify/incidents", CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); Assert.NotNull(result); Assert.NotNull(result.Incidents); } [Fact] public async Task AckIncident_ReturnsNoContent() { // Arrange var request = new IncidentAckRequest { Actor = "test-user", Comment = "Acknowledged" }; // Act var response = await _client.PostAsJsonAsync("/api/v2/notify/incidents/incident-001/ack", request, cancellationToken: CancellationToken.None); // Assert Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } #endregion #region Error Handling Tests [Fact] public async Task AllEndpoints_ReturnBadRequest_WhenTenantMissing() { // Arrange var clientWithoutTenant = _factory.CreateClient(); // Act var response = await clientWithoutTenant.GetAsync("/api/v2/notify/rules", CancellationToken.None); // Assert - should fail without tenant header Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } #endregion #region Test Repositories private sealed class InMemoryRuleRepository : INotifyRuleRepository { private readonly Dictionary _rules = new(); public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default) { var key = $"{rule.TenantId}:{rule.RuleId}"; _rules[key] = rule; return Task.FromResult(rule); } public Task GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default) { var key = $"{tenantId}:{ruleId}"; return Task.FromResult(_rules.GetValueOrDefault(key)); } public Task> ListAsync(string tenantId, CancellationToken cancellationToken = default) { var result = _rules.Values.Where(r => r.TenantId == tenantId).ToList(); return Task.FromResult>(result); } public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default) { var key = $"{tenantId}:{ruleId}"; var removed = _rules.Remove(key); return Task.FromResult(removed); } } private sealed class InMemoryTemplateRepository : 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.FromResult(template); } 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}"; var removed = _templates.Remove(key); return Task.FromResult(removed); } } #endregion }