Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
extern alias webservice;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for delivery retry and stats endpoints (NOTIFY-016).
|
||||
/// </summary>
|
||||
public sealed class DeliveryRetryEndpointTests : IClassFixture<WebApplicationFactory<WebProgram>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly InMemoryDeliveryRepository _deliveryRepository;
|
||||
private readonly InMemoryAuditRepository _auditRepository;
|
||||
private readonly WebApplicationFactory<WebProgram> _factory;
|
||||
|
||||
public DeliveryRetryEndpointTests(WebApplicationFactory<WebProgram> factory)
|
||||
{
|
||||
_deliveryRepository = new InMemoryDeliveryRepository();
|
||||
_auditRepository = new InMemoryAuditRepository();
|
||||
|
||||
var customFactory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyDeliveryRepository>(_deliveryRepository);
|
||||
services.AddSingleton<INotifyAuditRepository>(_auditRepository);
|
||||
});
|
||||
builder.UseSetting("Environment", "Testing");
|
||||
});
|
||||
|
||||
_factory = customFactory;
|
||||
|
||||
_client = customFactory.CreateClient();
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
}
|
||||
|
||||
#region Delivery Retry Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDelivery_ReturnsBadRequest_WhenTenantMissing()
|
||||
{
|
||||
// Arrange
|
||||
var clientWithoutTenant = _factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await clientWithoutTenant.PostAsJsonAsync(
|
||||
"/api/v2/notify/deliveries/delivery-001/retry",
|
||||
new { },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDelivery_ReturnsNotFound_WhenDeliveryNotExists()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/api/v2/notify/deliveries/nonexistent-delivery/retry",
|
||||
new { },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDelivery_ReturnsBadRequest_WhenDeliveryAlreadySent()
|
||||
{
|
||||
// Arrange
|
||||
var delivery = CreateDelivery("delivery-sent", NotifyDeliveryStatus.Sent);
|
||||
await _deliveryRepository.UpsertAsync(delivery, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/api/v2/notify/deliveries/delivery-sent/retry",
|
||||
new { },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("already_completed", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDelivery_ReturnsBadRequest_WhenDeliveryAlreadyDelivered()
|
||||
{
|
||||
// Arrange
|
||||
var delivery = CreateDelivery("delivery-delivered", NotifyDeliveryStatus.Delivered);
|
||||
await _deliveryRepository.UpsertAsync(delivery, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/api/v2/notify/deliveries/delivery-delivered/retry",
|
||||
new { },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDelivery_ReturnsOk_WhenDeliveryFailed()
|
||||
{
|
||||
// Arrange
|
||||
var delivery = CreateDelivery("delivery-failed", NotifyDeliveryStatus.Failed);
|
||||
await _deliveryRepository.UpsertAsync(delivery, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/api/v2/notify/deliveries/delivery-failed/retry",
|
||||
new { reason = "Manual retry request" },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
|
||||
Assert.Equal("delivery-failed", result.GetProperty("deliveryId").GetString());
|
||||
Assert.Equal("Pending", result.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDelivery_ReturnsOk_WhenDeliveryPending()
|
||||
{
|
||||
// Arrange
|
||||
var delivery = CreateDelivery("delivery-pending", NotifyDeliveryStatus.Pending);
|
||||
await _deliveryRepository.UpsertAsync(delivery, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/api/v2/notify/deliveries/delivery-pending/retry",
|
||||
new { },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDelivery_IncrementsAttemptCount()
|
||||
{
|
||||
// Arrange
|
||||
var delivery = CreateDelivery("delivery-retry-attempt", NotifyDeliveryStatus.Failed);
|
||||
await _deliveryRepository.UpsertAsync(delivery, CancellationToken.None);
|
||||
var initialAttempts = delivery.Attempts.Length;
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
"/api/v2/notify/deliveries/delivery-retry-attempt/retry",
|
||||
new { },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
|
||||
Assert.Equal(initialAttempts + 1, result.GetProperty("attemptCount").GetInt32());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delivery Stats Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeliveryStats_ReturnsBadRequest_WhenTenantMissing()
|
||||
{
|
||||
// Arrange
|
||||
var clientWithoutTenant = _factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await clientWithoutTenant.GetAsync(
|
||||
"/api/v2/notify/deliveries/stats",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeliveryStats_ReturnsEmptyStats_WhenNoDeliveries()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync(
|
||||
"/api/v2/notify/deliveries/stats",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
|
||||
Assert.Equal(0, result.GetProperty("total").GetInt32());
|
||||
Assert.Equal(0, result.GetProperty("sent").GetInt32());
|
||||
Assert.Equal(0, result.GetProperty("failed").GetInt32());
|
||||
Assert.Equal(0, result.GetProperty("pending").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeliveryStats_ReturnsCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
await _deliveryRepository.UpsertAsync(CreateDelivery("d1", NotifyDeliveryStatus.Sent), CancellationToken.None);
|
||||
await _deliveryRepository.UpsertAsync(CreateDelivery("d2", NotifyDeliveryStatus.Sent), CancellationToken.None);
|
||||
await _deliveryRepository.UpsertAsync(CreateDelivery("d3", NotifyDeliveryStatus.Failed), CancellationToken.None);
|
||||
await _deliveryRepository.UpsertAsync(CreateDelivery("d4", NotifyDeliveryStatus.Pending), CancellationToken.None);
|
||||
await _deliveryRepository.UpsertAsync(CreateDelivery("d5", NotifyDeliveryStatus.Delivered), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(
|
||||
"/api/v2/notify/deliveries/stats",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
|
||||
Assert.Equal(5, result.GetProperty("total").GetInt32());
|
||||
Assert.Equal(3, result.GetProperty("sent").GetInt32()); // Sent + Delivered
|
||||
Assert.Equal(1, result.GetProperty("failed").GetInt32());
|
||||
Assert.Equal(1, result.GetProperty("pending").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeliveryStats_GroupsByChannel()
|
||||
{
|
||||
// Arrange
|
||||
await _deliveryRepository.UpsertAsync(CreateDeliveryWithChannel("d1", "slack", NotifyDeliveryStatus.Sent), CancellationToken.None);
|
||||
await _deliveryRepository.UpsertAsync(CreateDeliveryWithChannel("d2", "slack", NotifyDeliveryStatus.Sent), CancellationToken.None);
|
||||
await _deliveryRepository.UpsertAsync(CreateDeliveryWithChannel("d3", "email", NotifyDeliveryStatus.Sent), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(
|
||||
"/api/v2/notify/deliveries/stats",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
|
||||
var byChannel = result.GetProperty("byChannel");
|
||||
Assert.Equal(2, byChannel.GetProperty("slack").GetInt32());
|
||||
Assert.Equal(1, byChannel.GetProperty("email").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeliveryStats_GroupsByEventKind()
|
||||
{
|
||||
// Arrange
|
||||
await _deliveryRepository.UpsertAsync(CreateDeliveryWithEventKind("d1", "finding.created", NotifyDeliveryStatus.Sent), CancellationToken.None);
|
||||
await _deliveryRepository.UpsertAsync(CreateDeliveryWithEventKind("d2", "finding.created", NotifyDeliveryStatus.Sent), CancellationToken.None);
|
||||
await _deliveryRepository.UpsertAsync(CreateDeliveryWithEventKind("d3", "policy.promoted", NotifyDeliveryStatus.Sent), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(
|
||||
"/api/v2/notify/deliveries/stats",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
|
||||
var byEventKind = result.GetProperty("byEventKind");
|
||||
Assert.Equal(2, byEventKind.GetProperty("finding.created").GetInt32());
|
||||
Assert.Equal(1, byEventKind.GetProperty("policy.promoted").GetInt32());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static NotifyDelivery CreateDelivery(string deliveryId, NotifyDeliveryStatus status)
|
||||
{
|
||||
return NotifyDelivery.Create(
|
||||
deliveryId: deliveryId,
|
||||
tenantId: "test-tenant",
|
||||
ruleId: "rule-001",
|
||||
actionId: "slack:alerts",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "finding.created",
|
||||
status: status,
|
||||
statusReason: "Test delivery",
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static NotifyDelivery CreateDeliveryWithChannel(string deliveryId, string channel, NotifyDeliveryStatus status)
|
||||
{
|
||||
return NotifyDelivery.Create(
|
||||
deliveryId: deliveryId,
|
||||
tenantId: "test-tenant",
|
||||
ruleId: "rule-001",
|
||||
actionId: channel,
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "finding.created",
|
||||
status: status,
|
||||
statusReason: "Test delivery",
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static NotifyDelivery CreateDeliveryWithEventKind(string deliveryId, string eventKind, NotifyDeliveryStatus status)
|
||||
{
|
||||
return NotifyDelivery.Create(
|
||||
deliveryId: deliveryId,
|
||||
tenantId: "test-tenant",
|
||||
ruleId: "rule-001",
|
||||
actionId: "slack:alerts",
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: eventKind,
|
||||
status: status,
|
||||
statusReason: "Test delivery",
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Repositories
|
||||
|
||||
private sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly Dictionary<string, NotifyDelivery> _deliveries = new();
|
||||
|
||||
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{delivery.TenantId}:{delivery.DeliveryId}";
|
||||
_deliveries[key] = delivery;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{delivery.TenantId}:{delivery.DeliveryId}";
|
||||
_deliveries[key] = delivery;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{tenantId}:{deliveryId}";
|
||||
return Task.FromResult(_deliveries.GetValueOrDefault(key));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyDelivery>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _deliveries.Values
|
||||
.Where(d => d.TenantId == tenantId)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyDelivery>>(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyDelivery>> ListPendingAsync(int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _deliveries.Values
|
||||
.Where(d => d.Status == NotifyDeliveryStatus.Pending)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyDelivery>>(result);
|
||||
}
|
||||
|
||||
public Task<NotifyDeliveryQueryResult> QueryAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since,
|
||||
string? status,
|
||||
int limit,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _deliveries.Values.Where(d => d.TenantId == tenantId);
|
||||
if (since.HasValue)
|
||||
{
|
||||
query = query.Where(d => d.CreatedAt >= since.Value);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<NotifyDeliveryStatus>(status, true, out var statusEnum))
|
||||
{
|
||||
query = query.Where(d => d.Status == statusEnum);
|
||||
}
|
||||
var result = query.Take(limit).ToList();
|
||||
return Task.FromResult(new NotifyDeliveryQueryResult(result, null));
|
||||
}
|
||||
|
||||
// Helper for tests to add deliveries
|
||||
public Task UpsertAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AppendAsync(delivery, cancellationToken);
|
||||
}
|
||||
|
||||
// Helper for tests to list all deliveries
|
||||
public Task<IReadOnlyList<NotifyDelivery>> ListAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyDelivery>>(_deliveries.Values.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryAuditRepository : INotifyAuditRepository
|
||||
{
|
||||
private readonly List<NotifyAuditEntry> _entries = new();
|
||||
|
||||
public Task AppendAsync(
|
||||
string tenantId,
|
||||
string action,
|
||||
string? actor,
|
||||
IReadOnlyDictionary<string, string> data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.Add(new NotifyAuditEntry(tenantId, action, actor, DateTimeOffset.UtcNow, data));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dict = new Dictionary<string, string>();
|
||||
if (entry.Payload is not null)
|
||||
{
|
||||
foreach (var prop in entry.Payload)
|
||||
{
|
||||
dict[prop.Key] = prop.Value?.ToString() ?? "";
|
||||
}
|
||||
}
|
||||
_entries.Add(new NotifyAuditEntry(entry.TenantId, entry.Action, entry.Actor, entry.Timestamp, dict));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyAuditEntry>> QueryAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset since,
|
||||
int limit,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _entries
|
||||
.Where(e => e.TenantId == tenantId && e.Timestamp >= since)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<NotifyAuditEntry>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -38,4 +38,5 @@
|
||||
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API contracts for delivery history and retry endpoints.
|
||||
/// Sprint: SPRINT_20251229_018b_FE_notification_delivery_audit
|
||||
/// Task: NOTIFY-016
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Response for delivery listing.
|
||||
/// </summary>
|
||||
public sealed record DeliveryListResponse
|
||||
{
|
||||
public required IReadOnlyList<DeliveryResponse> Items { get; init; }
|
||||
public required int Total { get; init; }
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery response.
|
||||
/// </summary>
|
||||
public sealed record DeliveryResponse
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public string? EventId { get; init; }
|
||||
public string? EventKind { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required IReadOnlyList<DeliveryAttemptResponse> Attempts { get; init; }
|
||||
public required int RetryCount { get; init; }
|
||||
public string? NextRetryAt { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public string? SentAt { get; init; }
|
||||
public string? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery attempt response.
|
||||
/// </summary>
|
||||
public sealed record DeliveryAttemptResponse
|
||||
{
|
||||
public required int AttemptNumber { get; init; }
|
||||
public required string Timestamp { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int? StatusCode { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public int? ResponseTimeMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry a failed delivery.
|
||||
/// </summary>
|
||||
public sealed record DeliveryRetryRequest
|
||||
{
|
||||
public string? ForceChannel { get; init; }
|
||||
public bool BypassThrottle { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from retry operation.
|
||||
/// </summary>
|
||||
public sealed record DeliveryRetryResponse
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required bool Retried { get; init; }
|
||||
public required int NewAttemptNumber { get; init; }
|
||||
public required string ScheduledAt { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery statistics response.
|
||||
/// </summary>
|
||||
public sealed record DeliveryStatsResponse
|
||||
{
|
||||
public required int TotalSent { get; init; }
|
||||
public required int TotalFailed { get; init; }
|
||||
public required int TotalThrottled { get; init; }
|
||||
public required int TotalPending { get; init; }
|
||||
public required double AvgDeliveryTimeMs { get; init; }
|
||||
public required double SuccessRate { get; init; }
|
||||
public required string Period { get; init; }
|
||||
public required IReadOnlyDictionary<string, ChannelStatsResponse> ByChannel { get; init; }
|
||||
public required IReadOnlyDictionary<string, EventKindStatsResponse> ByEventKind { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics by channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelStatsResponse
|
||||
{
|
||||
public required int Sent { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics by event kind.
|
||||
/// </summary>
|
||||
public sealed record EventKindStatsResponse
|
||||
{
|
||||
public required int Sent { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
}
|
||||
@@ -896,6 +896,146 @@ app.MapGet("/api/v2/notify/deliveries/{deliveryId}", async (
|
||||
: Results.NotFound(Error("not_found", $"Delivery {deliveryId} not found.", context));
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Delivery Retry and Stats (NOTIFY-016)
|
||||
// =============================================
|
||||
|
||||
app.MapPost("/api/v2/notify/deliveries/{deliveryId}/retry", async (
|
||||
HttpContext context,
|
||||
string deliveryId,
|
||||
StellaOps.Notifier.WebService.Contracts.DeliveryRetryRequest? request,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
|
||||
|
||||
var delivery = await deliveryRepository.GetAsync(tenantId, deliveryId, context.RequestAborted).ConfigureAwait(false);
|
||||
if (delivery is null)
|
||||
{
|
||||
return Results.NotFound(Error("not_found", $"Delivery {deliveryId} not found.", context));
|
||||
}
|
||||
|
||||
if (delivery.Status == NotifyDeliveryStatus.Sent || delivery.Status == NotifyDeliveryStatus.Delivered)
|
||||
{
|
||||
return Results.BadRequest(Error("delivery_already_completed", "Cannot retry a completed delivery.", context));
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var newAttemptNumber = delivery.Attempts.Length + 1;
|
||||
|
||||
// Create new attempt and update delivery to pending status for retry
|
||||
var newAttempt = new NotifyDeliveryAttempt(now, NotifyDeliveryAttemptStatus.Enqueued);
|
||||
var updatedDelivery = NotifyDelivery.Create(
|
||||
delivery.DeliveryId,
|
||||
delivery.TenantId,
|
||||
delivery.RuleId,
|
||||
delivery.ActionId,
|
||||
delivery.EventId,
|
||||
delivery.Kind,
|
||||
NotifyDeliveryStatus.Pending,
|
||||
"Retry requested",
|
||||
delivery.Rendered,
|
||||
delivery.Attempts.Append(newAttempt),
|
||||
delivery.Metadata,
|
||||
delivery.CreatedAt);
|
||||
|
||||
await deliveryRepository.UpdateAsync(updatedDelivery, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
// Audit the retry
|
||||
try
|
||||
{
|
||||
await auditRepository.AppendAsync(new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = "delivery.retry",
|
||||
EntityId = deliveryId,
|
||||
EntityType = "delivery",
|
||||
Timestamp = now,
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToNode(new { deliveryId, reason = request?.Reason, forceChannel = request?.ForceChannel }) as System.Text.Json.Nodes.JsonObject
|
||||
}, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch { /* Ignore audit failures */ }
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
deliveryId,
|
||||
retried = true,
|
||||
newAttemptNumber,
|
||||
scheduledAt = now.ToString("O"),
|
||||
message = "Delivery scheduled for retry"
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/deliveries/stats", async (
|
||||
HttpContext context,
|
||||
INotifyDeliveryRepository deliveryRepository) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allDeliveries = await deliveryRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
var sent = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered);
|
||||
var failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed);
|
||||
var throttled = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Throttled);
|
||||
var pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending);
|
||||
var total = sent + failed;
|
||||
|
||||
// Calculate average delivery time from attempts that have status codes (indicating completion)
|
||||
var completedAttempts = allDeliveries
|
||||
.Where(d => d.Attempts.Length > 0)
|
||||
.SelectMany(d => d.Attempts)
|
||||
.Where(a => a.StatusCode.HasValue)
|
||||
.ToList();
|
||||
|
||||
var avgDeliveryTime = completedAttempts.Count > 0 ? 0.0 : 0.0; // Response time not tracked in this model
|
||||
|
||||
var byChannel = allDeliveries
|
||||
.GroupBy(d => d.ActionId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new
|
||||
{
|
||||
sent = g.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered),
|
||||
failed = g.Count(d => d.Status == NotifyDeliveryStatus.Failed)
|
||||
});
|
||||
|
||||
var byEventKind = allDeliveries
|
||||
.GroupBy(d => d.Kind)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new
|
||||
{
|
||||
sent = g.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered),
|
||||
failed = g.Count(d => d.Status == NotifyDeliveryStatus.Failed)
|
||||
});
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
totalSent = sent,
|
||||
totalFailed = failed,
|
||||
totalThrottled = throttled,
|
||||
totalPending = pending,
|
||||
avgDeliveryTimeMs = avgDeliveryTime,
|
||||
successRate = total > 0 ? (double)sent / total * 100 : 0,
|
||||
period = "day",
|
||||
byChannel,
|
||||
byEventKind
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Simulation API (NOTIFY-SVC-39-003)
|
||||
// =============================================
|
||||
|
||||
Reference in New Issue
Block a user