Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -117,7 +117,7 @@ public sealed class EmailConnectorErrorTests
// Assert
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue();
result.RetryAfterMs.Should().BeGreaterOrEqualTo(1000);
result.RetryAfterMs.Should().BeGreaterThanOrEqualTo(1000);
}
#endregion
@@ -324,7 +324,7 @@ public sealed class EmailConnectorErrorTests
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue();
result.ErrorCode.Should().Be("RATE_LIMITED");
result.RetryAfterMs.Should().BeGreaterOrEqualTo(60000, "should respect retry-after from server");
result.RetryAfterMs.Should().BeGreaterThanOrEqualTo(60000, "should respect retry-after from server");
}
#endregion

View File

@@ -1,11 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup> <ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Email/StellaOps.Notify.Connectors.Email.csproj" />
@@ -15,9 +18,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
</Project>

View File

@@ -69,7 +69,7 @@ public sealed class SlackConnectorErrorTests
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue();
result.ErrorCode.Should().Be("RATE_LIMITED");
result.RetryAfterMs.Should().BeGreaterOrEqualTo(30000, "should respect Retry-After header");
result.RetryAfterMs.Should().BeGreaterThanOrEqualTo(30000, "should respect Retry-After header");
}
/// <summary>
@@ -567,12 +567,12 @@ internal sealed class SlackConnector
// Validate
var validationError = Validate(notification);
if (validationError != null)
if (validationError is { } error)
{
result.Success = false;
result.ShouldRetry = false;
result.ErrorCode = validationError.Code;
result.ErrorMessage = validationError.Message;
result.ErrorCode = error.Code;
result.ErrorMessage = error.Message;
return result;
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
@@ -110,7 +110,6 @@ public sealed class SlackChannelTestProviderTests
private static string ComputeSecretHash(string secretRef)
{
using var sha = System.Security.Cryptography.SHA256.Create();
using StellaOps.TestKit;
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = sha.ComputeHash(bytes);
return System.Convert.ToHexString(hash, 0, 8).ToLowerInvariant();

View File

@@ -1,11 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup> <ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Slack/StellaOps.Notify.Connectors.Slack.csproj" />
@@ -15,9 +18,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
</Project>

View File

@@ -66,7 +66,7 @@ public sealed class TeamsConnectorErrorTests
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue();
result.ErrorCode.Should().Be("RATE_LIMITED");
result.RetryAfterMs.Should().BeGreaterOrEqualTo(60000);
result.RetryAfterMs.Should().BeGreaterThanOrEqualTo(60000);
}
/// <summary>
@@ -601,12 +601,12 @@ internal sealed class TeamsConnector
// Validate
var validationError = Validate(notification);
if (validationError != null)
if (validationError is { } error)
{
result.Success = false;
result.ShouldRetry = false;
result.ErrorCode = validationError.Code;
result.ErrorMessage = validationError.Message;
result.ErrorCode = error.Code;
result.ErrorMessage = error.Message;
return result;
}

View File

@@ -1,11 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup> <ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Teams/StellaOps.Notify.Connectors.Teams.csproj" />
@@ -15,9 +18,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
@@ -66,7 +66,6 @@ public sealed class TeamsChannelTestProviderTests
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.config.endpoint"]);
using var payload = JsonDocument.Parse(result.Preview.Body);
using StellaOps.TestKit;
Assert.Equal("message", payload.RootElement.GetProperty("type").GetString());
Assert.Equal(result.Preview.TextBody, payload.RootElement.GetProperty("text").GetString());
Assert.Equal(result.Preview.Summary, payload.RootElement.GetProperty("summary").GetString());

View File

@@ -87,7 +87,7 @@ public sealed class WebhookConnectorErrorHandlingTests
// Assert
result.Success.Should().BeFalse();
result.IsRetryable.Should().BeTrue();
result.Error.Should().Contain("timeout", StringComparison.OrdinalIgnoreCase);
result.Error.Should().ContainEquivalentOf("timeout");
}
/// <summary>
@@ -180,7 +180,7 @@ public sealed class WebhookConnectorErrorHandlingTests
result.Success.Should().BeFalse();
result.IsRetryable.Should().BeTrue();
result.RetryAfter.Should().NotBeNull();
result.RetryAfter!.Value.TotalSeconds.Should().BeGreaterOrEqualTo(60);
result.RetryAfter!.Value.TotalSeconds.Should().BeGreaterThanOrEqualTo(60);
}
#endregion
@@ -489,12 +489,13 @@ public sealed class WebhookConnector
RetryAfter = retryAfter
};
}
catch (OperationCanceledException)
{
throw;
}
catch (TaskCanceledException ex)
catch (TaskCanceledException ex) when (ex is not OperationCanceledException || ex.CancellationToken.IsCancellationRequested)
{
// Check if it was an actual cancellation request or a timeout
if (ex.CancellationToken.IsCancellationRequested)
{
throw;
}
return new WebhookSendResult
{
Success = false,

View File

@@ -32,7 +32,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new FailingWebhookClient(HttpStatusCode.ServiceUnavailable);
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions
{
MaxRetries = 3,
RetryDelayMs = 100
@@ -56,7 +56,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new FailingWebhookClient(HttpStatusCode.TooManyRequests, retryAfterSeconds: 30);
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -66,7 +66,7 @@ public sealed class WebhookConnectorErrorTests
result.Success.Should().BeFalse();
result.ShouldRetry.Should().BeTrue();
result.ErrorCode.Should().Be("RATE_LIMITED");
result.RetryAfterMs.Should().BeGreaterOrEqualTo(30000);
result.RetryAfterMs.Should().BeGreaterThanOrEqualTo(30000);
}
/// <summary>
@@ -77,7 +77,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new TimeoutWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -97,7 +97,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new DnsFailureWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -120,7 +120,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new FailingWebhookClient(statusCode);
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -143,7 +143,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new FailingWebhookClient(HttpStatusCode.NotFound);
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -163,7 +163,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new FailingWebhookClient(HttpStatusCode.Gone);
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -187,7 +187,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new FailingWebhookClient(HttpStatusCode.Unauthorized);
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -207,7 +207,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new FailingWebhookClient(HttpStatusCode.Forbidden);
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -231,7 +231,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new SucceedingWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = new WebhookNotification
{
NotificationId = "notif-001",
@@ -257,7 +257,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new SucceedingWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions { RequireHttps = true });
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions { RequireHttps = true });
var notification = new WebhookNotification
{
NotificationId = "notif-001",
@@ -285,7 +285,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new SucceedingWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions { AllowLocalhost = false });
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions { AllowLocalhost = false });
var notification = new WebhookNotification
{
NotificationId = "notif-001",
@@ -313,7 +313,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new SucceedingWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions { AllowPrivateIp = false });
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions { AllowPrivateIp = false });
var notification = new WebhookNotification
{
NotificationId = "notif-001",
@@ -338,7 +338,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new SucceedingWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions { MaxPayloadSize = 1000 });
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions { MaxPayloadSize = 1000 });
var notification = new WebhookNotification
{
NotificationId = "notif-001",
@@ -366,7 +366,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new FailingWebhookClient(HttpStatusCode.BadRequest, errorMessage: "Invalid JSON");
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -391,7 +391,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new SlowWebhookClient(TimeSpan.FromSeconds(10));
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(100));
@@ -417,7 +417,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new CapturingWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = new WebhookNotification
{
NotificationId = "notif-001",
@@ -442,7 +442,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new CapturingWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = new WebhookNotification
{
NotificationId = "notif-001",
@@ -470,7 +470,7 @@ public sealed class WebhookConnectorErrorTests
{
// Arrange
var httpClient = new CapturingWebhookClient();
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = new WebhookNotification
{
NotificationId = "notif-001",
@@ -519,7 +519,7 @@ public sealed class WebhookConnectorErrorTests
var httpClient = isSuccess
? (IWebhookClient)new SucceedingWebhookClient()
: new FailingWebhookClient(statusCode);
var connector = new WebhookConnector(httpClient, new WebhookConnectorOptions());
var connector = new TestWebhookConnector(httpClient, new TestWebhookConnectorOptions());
var notification = CreateTestNotification();
// Act
@@ -685,75 +685,91 @@ internal sealed class WebhookResponse
}
/// <summary>
/// Webhook notification model.
/// Webhook notification model for testing.
/// </summary>
internal sealed class WebhookNotification
{
public required string NotificationId { get; set; }
public required string Url { get; set; }
public required string Payload { get; set; }
public string NotificationId { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public string? Secret { get; set; }
public Dictionary<string, string>? Headers { get; set; }
}
/// <summary>
/// Webhook connector options.
/// Webhook connector options for testing.
/// </summary>
internal sealed class WebhookConnectorOptions
internal sealed class TestWebhookConnectorOptions
{
public int MaxRetries { get; set; } = 3;
public int RetryDelayMs { get; set; } = 1000;
public bool RequireHttps { get; set; }
public bool RequireHttps { get; set; } = false;
public bool AllowLocalhost { get; set; } = true;
public bool AllowPrivateIp { get; set; } = true;
public int MaxPayloadSize { get; set; } = 1_000_000;
}
/// <summary>
/// Webhook send result.
/// Webhook send result for testing.
/// </summary>
internal sealed class WebhookSendResult
internal sealed class TestWebhookSendResult
{
public bool Success { get; set; }
public bool ShouldRetry { get; set; }
public int RetryAfterMs { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string? NotificationId { get; set; }
public int RetryAfterMs { get; set; }
}
/// <summary>
/// Webhook connector for testing.
/// Test webhook connector that simulates sending webhooks.
/// </summary>
internal sealed class WebhookConnector
internal sealed class TestWebhookConnector
{
private readonly IWebhookClient _client;
private readonly WebhookConnectorOptions _options;
private readonly TestWebhookConnectorOptions _options;
public WebhookConnector(IWebhookClient client, WebhookConnectorOptions options)
public TestWebhookConnector(IWebhookClient client, TestWebhookConnectorOptions options)
{
_client = client;
_options = options;
}
public async Task<WebhookSendResult> SendAsync(WebhookNotification notification, CancellationToken cancellationToken)
public async Task<TestWebhookSendResult> SendAsync(WebhookNotification notification, CancellationToken cancellationToken)
{
var result = new WebhookSendResult
// Validate URL
if (string.IsNullOrWhiteSpace(notification.Url))
{
NotificationId = notification.NotificationId,
Timestamp = DateTime.UtcNow
};
return new TestWebhookSendResult { Success = false, ShouldRetry = false, ErrorCode = "VALIDATION_FAILED" };
}
// Validate
var validationError = Validate(notification);
if (validationError != null)
if (!Uri.TryCreate(notification.Url, UriKind.Absolute, out var uri))
{
result.Success = false;
result.ShouldRetry = false;
result.ErrorCode = validationError.Code;
result.ErrorMessage = validationError.Message;
return result;
return new TestWebhookSendResult { Success = false, ShouldRetry = false, ErrorCode = "VALIDATION_FAILED" };
}
// HTTPS validation
if (_options.RequireHttps && uri.Scheme != "https")
{
return new TestWebhookSendResult { Success = false, ShouldRetry = false, ErrorCode = "HTTPS_REQUIRED" };
}
// Localhost validation
if (!_options.AllowLocalhost && (uri.Host == "localhost" || uri.Host == "127.0.0.1" || uri.Host == "[::1]"))
{
return new TestWebhookSendResult { Success = false, ShouldRetry = false, ErrorCode = "LOCALHOST_NOT_ALLOWED" };
}
// Private IP validation
if (!_options.AllowPrivateIp && IsPrivateIp(uri.Host))
{
return new TestWebhookSendResult { Success = false, ShouldRetry = false, ErrorCode = "PRIVATE_IP_NOT_ALLOWED" };
}
// Payload size validation
if (notification.Payload.Length > _options.MaxPayloadSize)
{
return new TestWebhookSendResult { Success = false, ShouldRetry = false, ErrorCode = "PAYLOAD_TOO_LARGE" };
}
try
@@ -762,106 +778,103 @@ internal sealed class WebhookConnector
if (response.Success)
{
result.Success = true;
return result;
return new TestWebhookSendResult { Success = true };
}
return ClassifyHttpError(result, response);
return MapHttpStatusToResult(response);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
catch (TaskCanceledException ex) when (ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase))
{
result.Success = false;
result.ShouldRetry = true;
result.ErrorCode = "TIMEOUT";
return result;
return new TestWebhookSendResult { Success = false, ShouldRetry = true, ErrorCode = "TIMEOUT" };
}
catch (OperationCanceledException)
catch (TaskCanceledException)
{
result.Success = false;
result.ShouldRetry = true;
result.ErrorCode = "CANCELLED";
return result;
return new TestWebhookSendResult { Success = false, ShouldRetry = true, ErrorCode = "CANCELLED" };
}
catch (HttpRequestException ex) when (ex.Message.Contains("No such host"))
catch (HttpRequestException ex) when (ex.Message.Contains("No such host", StringComparison.OrdinalIgnoreCase))
{
result.Success = false;
result.ShouldRetry = true;
result.ErrorCode = "DNS_FAILURE";
result.ErrorMessage = ex.Message;
return result;
return new TestWebhookSendResult { Success = false, ShouldRetry = true, ErrorCode = "DNS_FAILURE" };
}
catch (Exception ex)
{
result.Success = false;
result.ShouldRetry = true;
result.ErrorCode = "UNKNOWN_ERROR";
result.ErrorMessage = ex.Message;
return result;
}
}
private (string Code, string Message)? Validate(WebhookNotification notification)
{
if (string.IsNullOrWhiteSpace(notification.Url))
return ("VALIDATION_FAILED", "URL is required");
if (!Uri.TryCreate(notification.Url, UriKind.Absolute, out var uri))
return ("VALIDATION_FAILED", "Invalid URL format");
if (_options.RequireHttps && uri.Scheme != "https")
return ("HTTPS_REQUIRED", "HTTPS is required");
if (!_options.AllowLocalhost && (uri.Host == "localhost" || uri.Host == "127.0.0.1" || uri.Host == "[::1]"))
return ("LOCALHOST_NOT_ALLOWED", "Localhost URLs are not allowed");
if (!_options.AllowPrivateIp && IsPrivateIp(uri.Host))
return ("PRIVATE_IP_NOT_ALLOWED", "Private IP addresses are not allowed");
if (notification.Payload?.Length > _options.MaxPayloadSize)
return ("PAYLOAD_TOO_LARGE", $"Payload exceeds {_options.MaxPayloadSize} byte limit");
return null;
}
private static bool IsPrivateIp(string host)
{
if (System.Net.IPAddress.TryParse(host, out var ip))
{
var bytes = ip.GetAddressBytes();
if (bytes.Length == 4) // IPv4
{
return bytes[0] == 10 ||
(bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) ||
(bytes[0] == 192 && bytes[1] == 168);
}
}
return false;
return host.StartsWith("192.168.") || host.StartsWith("10.") || host.StartsWith("172.16.");
}
private WebhookSendResult ClassifyHttpError(WebhookSendResult result, WebhookResponse response)
private static TestWebhookSendResult MapHttpStatusToResult(WebhookResponse response)
{
result.Success = false;
result.ErrorMessage = response.ErrorMessage;
(result.ErrorCode, result.ShouldRetry) = response.HttpStatusCode switch
return response.HttpStatusCode switch
{
HttpStatusCode.BadRequest => ("BAD_REQUEST", false),
HttpStatusCode.Unauthorized => ("UNAUTHORIZED", false),
HttpStatusCode.Forbidden => ("FORBIDDEN", false),
HttpStatusCode.NotFound => ("NOT_FOUND", false),
HttpStatusCode.Gone => ("GONE", false),
HttpStatusCode.TooManyRequests => ("RATE_LIMITED", true),
HttpStatusCode.InternalServerError => ("INTERNAL_SERVER_ERROR", true),
HttpStatusCode.BadGateway => ("BAD_GATEWAY", true),
HttpStatusCode.ServiceUnavailable => ("SERVICE_UNAVAILABLE", true),
HttpStatusCode.GatewayTimeout => ("GATEWAY_TIMEOUT", true),
_ => ("UNKNOWN_ERROR", true)
HttpStatusCode.BadRequest => new TestWebhookSendResult
{
Success = false,
ShouldRetry = false,
ErrorCode = "BAD_REQUEST",
ErrorMessage = response.ErrorMessage
},
HttpStatusCode.Unauthorized => new TestWebhookSendResult
{
Success = false,
ShouldRetry = false,
ErrorCode = "UNAUTHORIZED"
},
HttpStatusCode.Forbidden => new TestWebhookSendResult
{
Success = false,
ShouldRetry = false,
ErrorCode = "FORBIDDEN"
},
HttpStatusCode.NotFound => new TestWebhookSendResult
{
Success = false,
ShouldRetry = false,
ErrorCode = "NOT_FOUND"
},
HttpStatusCode.Gone => new TestWebhookSendResult
{
Success = false,
ShouldRetry = false,
ErrorCode = "GONE"
},
HttpStatusCode.TooManyRequests => new TestWebhookSendResult
{
Success = false,
ShouldRetry = true,
ErrorCode = "RATE_LIMITED",
RetryAfterMs = response.RetryAfterSeconds * 1000
},
HttpStatusCode.InternalServerError => new TestWebhookSendResult
{
Success = false,
ShouldRetry = true,
ErrorCode = "INTERNAL_SERVER_ERROR"
},
HttpStatusCode.ServiceUnavailable => new TestWebhookSendResult
{
Success = false,
ShouldRetry = true,
ErrorCode = "SERVICE_UNAVAILABLE"
},
HttpStatusCode.BadGateway => new TestWebhookSendResult
{
Success = false,
ShouldRetry = true,
ErrorCode = "BAD_GATEWAY"
},
HttpStatusCode.GatewayTimeout => new TestWebhookSendResult
{
Success = false,
ShouldRetry = true,
ErrorCode = "GATEWAY_TIMEOUT"
},
_ => new TestWebhookSendResult
{
Success = false,
ShouldRetry = (int)response.HttpStatusCode >= 500,
ErrorCode = response.HttpStatusCode.ToString().ToUpperInvariant()
}
};
if (response.RetryAfterSeconds > 0)
result.RetryAfterMs = response.RetryAfterSeconds * 1000;
return result;
}
}

View File

@@ -1,6 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@@ -16,17 +18,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup>
@@ -37,4 +31,4 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
</Project>

View File

@@ -296,7 +296,7 @@ public sealed class NotificationRateLimitingTests
// Assert
allowed.Should().BeFalse();
retryAfter.Should().BeGreaterThan(TimeSpan.Zero);
retryAfter.Should().BeLessOrEqualTo(TimeSpan.FromMilliseconds(5000));
retryAfter.Should().BeLessThanOrEqualTo(TimeSpan.FromMilliseconds(5000));
}
#endregion

View File

@@ -1,12 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
</Project>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
</Project>

View File

@@ -442,7 +442,7 @@ public sealed class NotificationTemplatingTests
var result = _renderer.Render(template, context);
// Assert
result.Body.Length.Should().BeLessOrEqualTo(23); // 20 + "..."
result.Body.Length.Should().BeLessThanOrEqualTo(23); // 20 + "..."
result.Body.Should().EndWith("...");
}
@@ -581,7 +581,7 @@ public sealed class NotificationTemplatingTests
public void SlackMrkdwn_SpecialCharsEscaped()
{
// Arrange
var template = CreateTemplate("Message: {{message}}", NotifyDeliveryFormat.SlackMrkdwn);
var template = CreateTemplate("Message: {{message}}", NotifyDeliveryFormat.Slack);
var context = new TemplateContext
{
Variables = new Dictionary<string, object>
@@ -668,7 +668,7 @@ public sealed class NotificationTemplatingTests
// Arrange
var template = CreateTemplate(
body: @"{""@type"":""MessageCard"",""title"":""{{title}}"",""text"":""{{message}}""}",
channelType: NotifyChannelType.MicrosoftTeams,
channelType: NotifyChannelType.Teams,
format: NotifyDeliveryFormat.Json);
var context = new TemplateContext
{
@@ -1259,7 +1259,7 @@ public sealed class NotificationTemplateRenderer
{
NotifyDeliveryFormat.Html => System.Net.WebUtility.HtmlEncode(stringValue),
NotifyDeliveryFormat.Markdown => EscapeMarkdown(stringValue),
NotifyDeliveryFormat.SlackMrkdwn => EscapeSlackMrkdwn(stringValue),
NotifyDeliveryFormat.Slack => EscapeSlackMrkdwn(stringValue),
_ => stringValue
};
}

View File

@@ -313,7 +313,7 @@ public class NotifyRateLimitingTests
public void CheckRateLimit_DefaultConfig_UsesDefaultLimits()
{
// Arrange
var defaultConfig = NotifyThrottleConfig.CreateDefault(TestTenantId, "default-config");
var defaultConfig = NotifyThrottleConfig.CreateDefault(TestTenantId, createdBy: "default-config");
var limiter = new NotifyRateLimiter(new FakeTimeProvider(FixedTimestamp));
// Act

View File

@@ -1,11 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup> <ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
@@ -13,11 +16,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
</Project>
</Project>

View File

@@ -31,7 +31,7 @@ public sealed class PlatformEventSchemaValidationTests
Assert.True(File.Exists(samplePath), $"Sample '{sampleFile}' not found at '{samplePath}'.");
Assert.True(File.Exists(schemaPath), $"Schema '{schemaFile}' not found at '{schemaPath}'.");
var schema = await JsonSchema.FromJsonAsync(File.ReadAllText(schemaPath));
var schema = await JsonSchema.FromJsonAsync(File.ReadAllText(schemaPath), CancellationToken.None);
var errors = schema.Validate(File.ReadAllText(samplePath));
if (errors.Count > 0)

View File

@@ -1,9 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
@@ -12,7 +16,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="NJsonSchema" Version="10.9.0" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NJsonSchema" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
<None Include="../../../../docs/events/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@@ -23,4 +30,4 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
</Project>

View File

@@ -1,12 +1,13 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
[Collection(NotifyPostgresCollection.Name)]
public sealed class ChannelRepositoryTests : IAsyncLifetime
@@ -25,8 +26,8 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
_repository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new ValueTask(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -44,8 +45,8 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
};
// Act
await _repository.CreateAsync(channel);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id);
await _repository.CreateAsync(channel, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id, CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -60,10 +61,10 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
{
// Arrange
var channel = CreateChannel("slack-alerts", ChannelType.Slack);
await _repository.CreateAsync(channel);
await _repository.CreateAsync(channel, CancellationToken.None);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "slack-alerts");
var fetched = await _repository.GetByNameAsync(_tenantId, "slack-alerts", CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -77,11 +78,11 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
// Arrange
var channel1 = CreateChannel("channel1", ChannelType.Email);
var channel2 = CreateChannel("channel2", ChannelType.Slack);
await _repository.CreateAsync(channel1);
await _repository.CreateAsync(channel2);
await _repository.CreateAsync(channel1, CancellationToken.None);
await _repository.CreateAsync(channel2, CancellationToken.None);
// Act
var channels = await _repository.GetAllAsync(_tenantId);
var channels = await _repository.GetAllAsync(_tenantId, cancellationToken: CancellationToken.None);
// Assert
channels.Should().HaveCount(2);
@@ -102,11 +103,11 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = false
};
await _repository.CreateAsync(enabledChannel);
await _repository.CreateAsync(disabledChannel);
await _repository.CreateAsync(enabledChannel, CancellationToken.None);
await _repository.CreateAsync(disabledChannel, CancellationToken.None);
// Act
var enabledChannels = await _repository.GetAllAsync(_tenantId, enabled: true);
var enabledChannels = await _repository.GetAllAsync(_tenantId, enabled: true, cancellationToken: CancellationToken.None);
// Assert
enabledChannels.Should().HaveCount(1);
@@ -120,11 +121,11 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
// Arrange
var emailChannel = CreateChannel("email", ChannelType.Email);
var slackChannel = CreateChannel("slack", ChannelType.Slack);
await _repository.CreateAsync(emailChannel);
await _repository.CreateAsync(slackChannel);
await _repository.CreateAsync(emailChannel, CancellationToken.None);
await _repository.CreateAsync(slackChannel, CancellationToken.None);
// Act
var slackChannels = await _repository.GetAllAsync(_tenantId, channelType: ChannelType.Slack);
var slackChannels = await _repository.GetAllAsync(_tenantId, channelType: ChannelType.Slack, cancellationToken: CancellationToken.None);
// Assert
slackChannels.Should().HaveCount(1);
@@ -137,7 +138,7 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
{
// Arrange
var channel = CreateChannel("update-test", ChannelType.Email);
await _repository.CreateAsync(channel);
await _repository.CreateAsync(channel, CancellationToken.None);
// Act
var updated = new ChannelEntity
@@ -149,8 +150,8 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
Enabled = false,
Config = "{\"updated\": true}"
};
var result = await _repository.UpdateAsync(updated);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id);
var result = await _repository.UpdateAsync(updated, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id, CancellationToken.None);
// Assert
result.Should().BeTrue();
@@ -164,11 +165,11 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
{
// Arrange
var channel = CreateChannel("delete-test", ChannelType.Email);
await _repository.CreateAsync(channel);
await _repository.CreateAsync(channel, CancellationToken.None);
// Act
var result = await _repository.DeleteAsync(_tenantId, channel.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id);
var result = await _repository.DeleteAsync(_tenantId, channel.Id, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, channel.Id, CancellationToken.None);
// Assert
result.Should().BeTrue();
@@ -190,12 +191,12 @@ public sealed class ChannelRepositoryTests : IAsyncLifetime
Enabled = false
};
var enabledSlack = CreateChannel("enabled-slack", ChannelType.Slack);
await _repository.CreateAsync(enabledEmail);
await _repository.CreateAsync(disabledEmail);
await _repository.CreateAsync(enabledSlack);
await _repository.CreateAsync(enabledEmail, CancellationToken.None);
await _repository.CreateAsync(disabledEmail, CancellationToken.None);
await _repository.CreateAsync(enabledSlack, CancellationToken.None);
// Act
var channels = await _repository.GetEnabledByTypeAsync(_tenantId, ChannelType.Email);
var channels = await _repository.GetEnabledByTypeAsync(_tenantId, ChannelType.Email, CancellationToken.None);
// Assert
channels.Should().HaveCount(1);

View File

@@ -8,12 +8,12 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
/// <summary>
/// Idempotency tests for Notify delivery storage operations.
@@ -39,7 +39,7 @@ public sealed class DeliveryIdempotencyTests : IAsyncLifetime
_fixture = fixture;
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.ExecuteSqlAsync(
"TRUNCATE TABLE notify.audit, notify.deliveries, notify.digests, notify.channels RESTART IDENTITY CASCADE;");
@@ -62,7 +62,7 @@ public sealed class DeliveryIdempotencyTests : IAsyncLifetime
});
}
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task CreateDelivery_SameId_SecondInsertFails()

View File

@@ -1,12 +1,12 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
[Collection(NotifyPostgresCollection.Name)]
public sealed class DeliveryRepositoryTests : IAsyncLifetime
@@ -28,8 +28,8 @@ public sealed class DeliveryRepositoryTests : IAsyncLifetime
_channelRepository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private async Task ResetAsync()
{

View File

@@ -1,12 +1,13 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
/// <summary>
/// End-to-end tests for digest aggregation (PG-T3.10.4).
@@ -36,8 +37,8 @@ public sealed class DigestAggregationTests : IAsyncLifetime
_maintenanceRepository = new MaintenanceWindowRepository(dataSource, NullLogger<MaintenanceWindowRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new ValueTask(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -52,7 +53,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
// Create digest in collecting state
var digest = new DigestEntity
@@ -67,27 +68,27 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
await _digestRepository.UpsertAsync(digest, cancellationToken: CancellationToken.None);
// Act - Add events to digest
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0001"}""");
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0002"}""");
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0003"}""");
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0001"}""", cancellationToken: CancellationToken.None);
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0002"}""", cancellationToken: CancellationToken.None);
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0003"}""", cancellationToken: CancellationToken.None);
var afterEvents = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
var afterEvents = await _digestRepository.GetByIdAsync(_tenantId, digest.Id, cancellationToken: CancellationToken.None);
afterEvents!.EventCount.Should().Be(3);
afterEvents.Events.Should().Contain("CVE-2025-0001");
afterEvents.Events.Should().Contain("CVE-2025-0002");
afterEvents.Events.Should().Contain("CVE-2025-0003");
// Transition to sending
await _digestRepository.MarkSendingAsync(_tenantId, digest.Id);
var sending = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
await _digestRepository.MarkSendingAsync(_tenantId, digest.Id, cancellationToken: CancellationToken.None);
var sending = await _digestRepository.GetByIdAsync(_tenantId, digest.Id, cancellationToken: CancellationToken.None);
sending!.Status.Should().Be(DigestStatus.Sending);
// Transition to sent
await _digestRepository.MarkSentAsync(_tenantId, digest.Id);
var sent = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
await _digestRepository.MarkSentAsync(_tenantId, digest.Id, cancellationToken: CancellationToken.None);
var sent = await _digestRepository.GetByIdAsync(_tenantId, digest.Id, cancellationToken: CancellationToken.None);
sent!.Status.Should().Be(DigestStatus.Sent);
sent.SentAt.Should().NotBeNull();
}
@@ -105,7 +106,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
// Create digest that's ready (collect window passed)
var readyDigest = new DigestEntity
@@ -133,11 +134,11 @@ public sealed class DigestAggregationTests : IAsyncLifetime
CollectUntil = DateTimeOffset.UtcNow.AddHours(1) // Still collecting
};
await _digestRepository.UpsertAsync(readyDigest);
await _digestRepository.UpsertAsync(notReadyDigest);
await _digestRepository.UpsertAsync(readyDigest, cancellationToken: CancellationToken.None);
await _digestRepository.UpsertAsync(notReadyDigest, cancellationToken: CancellationToken.None);
// Act
var ready = await _digestRepository.GetReadyToSendAsync();
var ready = await _digestRepository.GetReadyToSendAsync(cancellationToken: CancellationToken.None);
// Assert
ready.Should().Contain(d => d.Id == readyDigest.Id);
@@ -157,7 +158,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
var digestKey = "hourly-alerts";
var recipient = "alerts@example.com";
@@ -172,10 +173,10 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
await _digestRepository.UpsertAsync(digest, cancellationToken: CancellationToken.None);
// Act
var fetched = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, recipient, digestKey);
var fetched = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, recipient, digestKey, cancellationToken: CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -196,7 +197,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
var digest = new DigestEntity
{
@@ -210,7 +211,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
await _digestRepository.UpsertAsync(digest, cancellationToken: CancellationToken.None);
// Act - Upsert with updated collect window
var updated = new DigestEntity
@@ -225,10 +226,10 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(2) // Extended
};
await _digestRepository.UpsertAsync(updated);
await _digestRepository.UpsertAsync(updated, cancellationToken: CancellationToken.None);
// Assert
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id, cancellationToken: CancellationToken.None);
fetched!.CollectUntil.Should().BeCloseTo(DateTimeOffset.UtcNow.AddHours(2), TimeSpan.FromMinutes(1));
}
@@ -245,7 +246,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
// Create old sent digest
var oldDigest = new DigestEntity
@@ -258,8 +259,8 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Sent,
CollectUntil = DateTimeOffset.UtcNow.AddDays(-10)
};
await _digestRepository.UpsertAsync(oldDigest);
await _digestRepository.MarkSentAsync(_tenantId, oldDigest.Id);
await _digestRepository.UpsertAsync(oldDigest, cancellationToken: CancellationToken.None);
await _digestRepository.MarkSentAsync(_tenantId, oldDigest.Id, cancellationToken: CancellationToken.None);
// Create recent digest
var recentDigest = new DigestEntity
@@ -272,18 +273,18 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(recentDigest);
await _digestRepository.UpsertAsync(recentDigest, cancellationToken: CancellationToken.None);
// Act - Delete digests older than 7 days
var cutoff = DateTimeOffset.UtcNow.AddDays(-7);
var deleted = await _digestRepository.DeleteOldAsync(cutoff);
var deleted = await _digestRepository.DeleteOldAsync(cutoff, cancellationToken: CancellationToken.None);
// Assert
deleted.Should().BeGreaterThanOrEqualTo(1);
var oldFetch = await _digestRepository.GetByIdAsync(_tenantId, oldDigest.Id);
var oldFetch = await _digestRepository.GetByIdAsync(_tenantId, oldDigest.Id, cancellationToken: CancellationToken.None);
oldFetch.Should().BeNull();
var recentFetch = await _digestRepository.GetByIdAsync(_tenantId, recentDigest.Id);
var recentFetch = await _digestRepository.GetByIdAsync(_tenantId, recentDigest.Id, cancellationToken: CancellationToken.None);
recentFetch.Should().NotBeNull();
}
@@ -300,7 +301,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
var digestKey = "shared-key";
var digest1 = new DigestEntity
@@ -323,12 +324,12 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest1);
await _digestRepository.UpsertAsync(digest2);
await _digestRepository.UpsertAsync(digest1, cancellationToken: CancellationToken.None);
await _digestRepository.UpsertAsync(digest2, cancellationToken: CancellationToken.None);
// Act
var fetched1 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user1@example.com", digestKey);
var fetched2 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user2@example.com", digestKey);
var fetched1 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user1@example.com", digestKey, cancellationToken: CancellationToken.None);
var fetched2 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user2@example.com", digestKey, cancellationToken: CancellationToken.None);
// Assert
fetched1.Should().NotBeNull();
@@ -349,7 +350,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
var digest = new DigestEntity
{
@@ -363,16 +364,16 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
await _digestRepository.UpsertAsync(digest, cancellationToken: CancellationToken.None);
// Act - Add 10 events
for (int i = 1; i <= 10; i++)
{
await _digestRepository.AddEventAsync(_tenantId, digest.Id, $$$"""{"id": {{{i}}}, "type": "scan.finding"}""");
await _digestRepository.AddEventAsync(_tenantId, digest.Id, $$$"""{"id": {{{i}}}, "type": "scan.finding"}""", cancellationToken: CancellationToken.None);
}
// Assert
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id, cancellationToken: CancellationToken.None);
fetched!.EventCount.Should().Be(10);
// Parse events JSON to verify all events are there
@@ -399,10 +400,10 @@ public sealed class DigestAggregationTests : IAsyncLifetime
DaysOfWeek = [1, 2, 3, 4, 5], // Weekdays
Enabled = true
};
await _quietHoursRepository.CreateAsync(quietHours);
await _quietHoursRepository.CreateAsync(quietHours, cancellationToken: CancellationToken.None);
// Act
var fetched = await _quietHoursRepository.GetForUserAsync(_tenantId, userId);
var fetched = await _quietHoursRepository.GetForUserAsync(_tenantId, userId, cancellationToken: CancellationToken.None);
// Assert
fetched.Should().ContainSingle();
@@ -428,15 +429,15 @@ public sealed class DigestAggregationTests : IAsyncLifetime
SuppressChannels = [suppressChannel],
SuppressEventTypes = ["scan.completed", "vulnerability.detected"]
};
await _maintenanceRepository.CreateAsync(window);
await _maintenanceRepository.CreateAsync(window, cancellationToken: CancellationToken.None);
// Act
var active = await _maintenanceRepository.GetActiveAsync(_tenantId);
var active = await _maintenanceRepository.GetActiveAsync(_tenantId, cancellationToken: CancellationToken.None);
// Assert - No active windows right now since it's scheduled for tomorrow
active.Should().BeEmpty();
var all = await _maintenanceRepository.ListAsync(_tenantId);
var all = await _maintenanceRepository.ListAsync(_tenantId, cancellationToken: CancellationToken.None);
all.Should().ContainSingle(w => w.Id == window.Id);
}
@@ -453,7 +454,7 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
// Create multiple digests
for (int i = 0; i < 10; i++)
@@ -468,13 +469,13 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddMinutes(-i) // All ready
};
await _digestRepository.UpsertAsync(digest);
await _digestRepository.UpsertAsync(digest, cancellationToken: CancellationToken.None);
}
// Act
var results1 = await _digestRepository.GetReadyToSendAsync(limit: 100);
var results2 = await _digestRepository.GetReadyToSendAsync(limit: 100);
var results3 = await _digestRepository.GetReadyToSendAsync(limit: 100);
var results1 = await _digestRepository.GetReadyToSendAsync(limit: 100, cancellationToken: CancellationToken.None);
var results2 = await _digestRepository.GetReadyToSendAsync(limit: 100, cancellationToken: CancellationToken.None);
var results3 = await _digestRepository.GetReadyToSendAsync(limit: 100, cancellationToken: CancellationToken.None);
// Assert
var ids1 = results1.Select(d => d.Id).ToList();
@@ -509,8 +510,8 @@ public sealed class DigestAggregationTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel1);
await _channelRepository.CreateAsync(channel2);
await _channelRepository.CreateAsync(channel1, cancellationToken: CancellationToken.None);
await _channelRepository.CreateAsync(channel2, cancellationToken: CancellationToken.None);
var digest1 = new DigestEntity
{
@@ -532,16 +533,16 @@ public sealed class DigestAggregationTests : IAsyncLifetime
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest1);
await _digestRepository.UpsertAsync(digest2);
await _digestRepository.UpsertAsync(digest1, cancellationToken: CancellationToken.None);
await _digestRepository.UpsertAsync(digest2, cancellationToken: CancellationToken.None);
// Act
var tenant1Fetch = await _digestRepository.GetByKeyAsync(tenant1, channel1.Id, "user@tenant1.com", "shared-key");
var tenant2Fetch = await _digestRepository.GetByKeyAsync(tenant2, channel2.Id, "user@tenant2.com", "shared-key");
var tenant1Fetch = await _digestRepository.GetByKeyAsync(tenant1, channel1.Id, "user@tenant1.com", "shared-key", cancellationToken: CancellationToken.None);
var tenant2Fetch = await _digestRepository.GetByKeyAsync(tenant2, channel2.Id, "user@tenant2.com", "shared-key", cancellationToken: CancellationToken.None);
// Cross-tenant attempts should fail
var crossFetch1 = await _digestRepository.GetByIdAsync(tenant1, digest2.Id);
var crossFetch2 = await _digestRepository.GetByIdAsync(tenant2, digest1.Id);
var crossFetch1 = await _digestRepository.GetByIdAsync(tenant1, digest2.Id, cancellationToken: CancellationToken.None);
var crossFetch2 = await _digestRepository.GetByIdAsync(tenant2, digest1.Id, cancellationToken: CancellationToken.None);
// Assert
tenant1Fetch.Should().NotBeNull();

View File

@@ -1,12 +1,12 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
[Collection(NotifyPostgresCollection.Name)]
public sealed class DigestRepositoryTests : IAsyncLifetime
@@ -28,8 +28,8 @@ public sealed class DigestRepositoryTests : IAsyncLifetime
_channelRepository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private async Task ResetAsync()
{

View File

@@ -1,12 +1,13 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
/// <summary>
/// End-to-end tests for escalation handling (PG-T3.10.3).
@@ -36,8 +37,8 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
_incidentRepository = new IncidentRepository(dataSource, NullLogger<IncidentRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new ValueTask(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -60,7 +61,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
""",
RepeatCount = 2
};
await _policyRepository.CreateAsync(policy);
await _policyRepository.CreateAsync(policy, cancellationToken: CancellationToken.None);
// Create incident
var incident = new IncidentEntity
@@ -72,7 +73,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Status = IncidentStatus.Open,
CorrelationId = Guid.NewGuid().ToString()
};
await _incidentRepository.CreateAsync(incident);
await _incidentRepository.CreateAsync(incident, cancellationToken: CancellationToken.None);
// Act - Start escalation
var escalationState = new EscalationStateEntity
@@ -87,10 +88,10 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
StartedAt = DateTimeOffset.UtcNow,
NextEscalationAt = DateTimeOffset.UtcNow.AddMinutes(5)
};
await _stateRepository.CreateAsync(escalationState);
await _stateRepository.CreateAsync(escalationState, cancellationToken: CancellationToken.None);
// Verify active
var active = await _stateRepository.GetActiveAsync();
var active = await _stateRepository.GetActiveAsync(cancellationToken: CancellationToken.None);
active.Should().Contain(s => s.Id == escalationState.Id);
// Escalate to step 2
@@ -98,28 +99,29 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
_tenantId,
escalationState.Id,
newStep: 2,
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(10));
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(10),
cancellationToken: CancellationToken.None);
var afterStep2 = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
var afterStep2 = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id, cancellationToken: CancellationToken.None);
afterStep2!.CurrentStep.Should().Be(2);
afterStep2.Status.Should().Be(EscalationStatus.Active);
// Acknowledge
await _stateRepository.AcknowledgeAsync(_tenantId, escalationState.Id, "oncall@example.com");
var acknowledged = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
await _stateRepository.AcknowledgeAsync(_tenantId, escalationState.Id, "oncall@example.com", cancellationToken: CancellationToken.None);
var acknowledged = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id, cancellationToken: CancellationToken.None);
acknowledged!.Status.Should().Be(EscalationStatus.Acknowledged);
acknowledged.AcknowledgedBy.Should().Be("oncall@example.com");
acknowledged.AcknowledgedAt.Should().NotBeNull();
// Resolve
await _stateRepository.ResolveAsync(_tenantId, escalationState.Id, "responder@example.com");
var resolved = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
await _stateRepository.ResolveAsync(_tenantId, escalationState.Id, "responder@example.com", cancellationToken: CancellationToken.None);
var resolved = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id, cancellationToken: CancellationToken.None);
resolved!.Status.Should().Be(EscalationStatus.Resolved);
resolved.ResolvedBy.Should().Be("responder@example.com");
resolved.ResolvedAt.Should().NotBeNull();
// No longer in active list
var finalActive = await _stateRepository.GetActiveAsync();
var finalActive = await _stateRepository.GetActiveAsync(cancellationToken: CancellationToken.None);
finalActive.Should().NotContain(s => s.Id == escalationState.Id);
}
@@ -137,7 +139,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Steps = """[{"step": 1}, {"step": 2}, {"step": 3}, {"step": 4}]""",
RepeatCount = 0
};
await _policyRepository.CreateAsync(policy);
await _policyRepository.CreateAsync(policy, cancellationToken: CancellationToken.None);
var state = new EscalationStateEntity
{
@@ -149,7 +151,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
await _stateRepository.CreateAsync(state, cancellationToken: CancellationToken.None);
// Act - Progress through all steps
for (int step = 2; step <= 4; step++)
@@ -158,14 +160,15 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
_tenantId,
state.Id,
newStep: step,
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(step * 5));
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(step * 5),
cancellationToken: CancellationToken.None);
var current = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
var current = await _stateRepository.GetByIdAsync(_tenantId, state.Id, cancellationToken: CancellationToken.None);
current!.CurrentStep.Should().Be(step);
}
// Assert final state
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id, cancellationToken: CancellationToken.None);
final!.CurrentStep.Should().Be(4);
final.Status.Should().Be(EscalationStatus.Active);
}
@@ -182,7 +185,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Name = "correlation-test-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
await _policyRepository.CreateAsync(policy, cancellationToken: CancellationToken.None);
var correlationId = $"incident-{Guid.NewGuid():N}";
var state = new EscalationStateEntity
@@ -195,10 +198,10 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
await _stateRepository.CreateAsync(state, cancellationToken: CancellationToken.None);
// Act
var fetched = await _stateRepository.GetByCorrelationIdAsync(_tenantId, correlationId);
var fetched = await _stateRepository.GetByCorrelationIdAsync(_tenantId, correlationId, cancellationToken: CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -229,12 +232,12 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Participants = """["charlie@example.com", "diana@example.com"]""",
Timezone = "UTC"
};
await _onCallRepository.CreateAsync(primarySchedule);
await _onCallRepository.CreateAsync(secondarySchedule);
await _onCallRepository.CreateAsync(primarySchedule, cancellationToken: CancellationToken.None);
await _onCallRepository.CreateAsync(secondarySchedule, cancellationToken: CancellationToken.None);
// Act
var primary = await _onCallRepository.GetByNameAsync(_tenantId, "primary-oncall");
var secondary = await _onCallRepository.GetByNameAsync(_tenantId, "secondary-oncall");
var primary = await _onCallRepository.GetByNameAsync(_tenantId, "primary-oncall", cancellationToken: CancellationToken.None);
var secondary = await _onCallRepository.GetByNameAsync(_tenantId, "secondary-oncall", cancellationToken: CancellationToken.None);
// Assert
primary.Should().NotBeNull();
@@ -255,7 +258,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Name = "multi-active-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
await _policyRepository.CreateAsync(policy, cancellationToken: CancellationToken.None);
// Create multiple active escalations
var states = new List<EscalationStateEntity>();
@@ -271,12 +274,12 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-i)
};
await _stateRepository.CreateAsync(state);
await _stateRepository.CreateAsync(state, cancellationToken: CancellationToken.None);
states.Add(state);
}
// Act
var active = await _stateRepository.GetActiveAsync(limit: 100);
var active = await _stateRepository.GetActiveAsync(limit: 100, cancellationToken: CancellationToken.None);
// Assert
foreach (var state in states)
@@ -304,11 +307,11 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Name = "disabled-policy",
Enabled = false
};
await _policyRepository.CreateAsync(enabledPolicy);
await _policyRepository.CreateAsync(disabledPolicy);
await _policyRepository.CreateAsync(enabledPolicy, cancellationToken: CancellationToken.None);
await _policyRepository.CreateAsync(disabledPolicy, cancellationToken: CancellationToken.None);
// Act
var allPolicies = await _policyRepository.ListAsync(_tenantId);
var allPolicies = await _policyRepository.ListAsync(_tenantId, cancellationToken: CancellationToken.None);
var enabledOnly = allPolicies.Where(p => p.Enabled).ToList();
// Assert
@@ -328,7 +331,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Name = "incident-linked-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
await _policyRepository.CreateAsync(policy, cancellationToken: CancellationToken.None);
var incident = new IncidentEntity
{
@@ -339,7 +342,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Status = IncidentStatus.Open,
CorrelationId = Guid.NewGuid().ToString()
};
await _incidentRepository.CreateAsync(incident);
await _incidentRepository.CreateAsync(incident, cancellationToken: CancellationToken.None);
var state = new EscalationStateEntity
{
@@ -352,10 +355,10 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
await _stateRepository.CreateAsync(state, cancellationToken: CancellationToken.None);
// Act
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id, cancellationToken: CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -375,7 +378,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Enabled = true,
RepeatCount = 3
};
await _policyRepository.CreateAsync(policy);
await _policyRepository.CreateAsync(policy, cancellationToken: CancellationToken.None);
// Act - Create state at repeat iteration 2
var state = new EscalationStateEntity
@@ -389,10 +392,10 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
await _stateRepository.CreateAsync(state, cancellationToken: CancellationToken.None);
// Assert
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id, cancellationToken: CancellationToken.None);
fetched!.RepeatIteration.Should().Be(2);
}
@@ -408,7 +411,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Name = "determinism-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
await _policyRepository.CreateAsync(policy, cancellationToken: CancellationToken.None);
// Create multiple active escalations
for (int i = 0; i < 10; i++)
@@ -422,13 +425,13 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow.AddSeconds(i)
});
}, cancellationToken: CancellationToken.None);
}
// Act
var results1 = await _stateRepository.GetActiveAsync(limit: 100);
var results2 = await _stateRepository.GetActiveAsync(limit: 100);
var results3 = await _stateRepository.GetActiveAsync(limit: 100);
var results1 = await _stateRepository.GetActiveAsync(limit: 100, cancellationToken: CancellationToken.None);
var results2 = await _stateRepository.GetActiveAsync(limit: 100, cancellationToken: CancellationToken.None);
var results3 = await _stateRepository.GetActiveAsync(limit: 100, cancellationToken: CancellationToken.None);
// Assert
var ids1 = results1.Select(s => s.Id).ToList();
@@ -452,7 +455,7 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
Enabled = true,
Metadata = """{"severity_levels": ["low", "medium", "high", "critical"]}"""
};
await _policyRepository.CreateAsync(policy);
await _policyRepository.CreateAsync(policy, cancellationToken: CancellationToken.None);
var state = new EscalationStateEntity
{
@@ -465,15 +468,15 @@ public sealed class EscalationHandlingTests : IAsyncLifetime
StartedAt = DateTimeOffset.UtcNow,
Metadata = """{"original_severity": "critical", "source": "scanner"}"""
};
await _stateRepository.CreateAsync(state);
await _stateRepository.CreateAsync(state, cancellationToken: CancellationToken.None);
// Act
await _stateRepository.EscalateAsync(_tenantId, state.Id, 2, DateTimeOffset.UtcNow.AddMinutes(5));
await _stateRepository.AcknowledgeAsync(_tenantId, state.Id, "responder");
await _stateRepository.ResolveAsync(_tenantId, state.Id, "responder");
await _stateRepository.EscalateAsync(_tenantId, state.Id, 2, DateTimeOffset.UtcNow.AddMinutes(5), cancellationToken: CancellationToken.None);
await _stateRepository.AcknowledgeAsync(_tenantId, state.Id, "responder", cancellationToken: CancellationToken.None);
await _stateRepository.ResolveAsync(_tenantId, state.Id, "responder", cancellationToken: CancellationToken.None);
// Assert
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id, cancellationToken: CancellationToken.None);
final!.Metadata.Should().Contain("original_severity");
final.Metadata.Should().Contain("scanner");
}

View File

@@ -1,12 +1,13 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
[Collection(NotifyPostgresCollection.Name)]
public sealed class InboxRepositoryTests : IAsyncLifetime
@@ -25,8 +26,8 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
_repository = new InboxRepository(dataSource, NullLogger<InboxRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new ValueTask(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -46,8 +47,8 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
};
// Act
await _repository.CreateAsync(inbox);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id);
await _repository.CreateAsync(inbox, cancellationToken: CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id, cancellationToken: CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -65,12 +66,12 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
var inbox1 = CreateInbox(userId, "Item 1");
var inbox2 = CreateInbox(userId, "Item 2");
var otherUserInbox = CreateInbox(Guid.NewGuid(), "Other user item");
await _repository.CreateAsync(inbox1);
await _repository.CreateAsync(inbox2);
await _repository.CreateAsync(otherUserInbox);
await _repository.CreateAsync(inbox1, cancellationToken: CancellationToken.None);
await _repository.CreateAsync(inbox2, cancellationToken: CancellationToken.None);
await _repository.CreateAsync(otherUserInbox, cancellationToken: CancellationToken.None);
// Act
var items = await _repository.GetForUserAsync(_tenantId, userId);
var items = await _repository.GetForUserAsync(_tenantId, userId, cancellationToken: CancellationToken.None);
// Assert
items.Should().HaveCount(2);
@@ -85,12 +86,12 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
var userId = Guid.NewGuid();
var unreadItem = CreateInbox(userId, "Unread");
var readItem = CreateInbox(userId, "Read");
await _repository.CreateAsync(unreadItem);
await _repository.CreateAsync(readItem);
await _repository.MarkReadAsync(_tenantId, readItem.Id);
await _repository.CreateAsync(unreadItem, cancellationToken: CancellationToken.None);
await _repository.CreateAsync(readItem, cancellationToken: CancellationToken.None);
await _repository.MarkReadAsync(_tenantId, readItem.Id, cancellationToken: CancellationToken.None);
// Act
var unreadItems = await _repository.GetForUserAsync(_tenantId, userId, unreadOnly: true);
var unreadItems = await _repository.GetForUserAsync(_tenantId, userId, unreadOnly: true, cancellationToken: CancellationToken.None);
// Assert
unreadItems.Should().HaveCount(1);
@@ -103,14 +104,14 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
{
// Arrange
var userId = Guid.NewGuid();
await _repository.CreateAsync(CreateInbox(userId, "Unread 1"));
await _repository.CreateAsync(CreateInbox(userId, "Unread 2"));
await _repository.CreateAsync(CreateInbox(userId, "Unread 1"), cancellationToken: CancellationToken.None);
await _repository.CreateAsync(CreateInbox(userId, "Unread 2"), cancellationToken: CancellationToken.None);
var readItem = CreateInbox(userId, "Read");
await _repository.CreateAsync(readItem);
await _repository.MarkReadAsync(_tenantId, readItem.Id);
await _repository.CreateAsync(readItem, cancellationToken: CancellationToken.None);
await _repository.MarkReadAsync(_tenantId, readItem.Id, cancellationToken: CancellationToken.None);
// Act
var count = await _repository.GetUnreadCountAsync(_tenantId, userId);
var count = await _repository.GetUnreadCountAsync(_tenantId, userId, cancellationToken: CancellationToken.None);
// Assert
count.Should().Be(2);
@@ -123,11 +124,11 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
// Arrange
var userId = Guid.NewGuid();
var inbox = CreateInbox(userId, "To be read");
await _repository.CreateAsync(inbox);
await _repository.CreateAsync(inbox, cancellationToken: CancellationToken.None);
// Act
var result = await _repository.MarkReadAsync(_tenantId, inbox.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id);
var result = await _repository.MarkReadAsync(_tenantId, inbox.Id, cancellationToken: CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id, cancellationToken: CancellationToken.None);
// Assert
result.Should().BeTrue();
@@ -141,13 +142,13 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
{
// Arrange
var userId = Guid.NewGuid();
await _repository.CreateAsync(CreateInbox(userId, "Item 1"));
await _repository.CreateAsync(CreateInbox(userId, "Item 2"));
await _repository.CreateAsync(CreateInbox(userId, "Item 3"));
await _repository.CreateAsync(CreateInbox(userId, "Item 1"), cancellationToken: CancellationToken.None);
await _repository.CreateAsync(CreateInbox(userId, "Item 2"), cancellationToken: CancellationToken.None);
await _repository.CreateAsync(CreateInbox(userId, "Item 3"), cancellationToken: CancellationToken.None);
// Act
var count = await _repository.MarkAllReadAsync(_tenantId, userId);
var unreadCount = await _repository.GetUnreadCountAsync(_tenantId, userId);
var count = await _repository.MarkAllReadAsync(_tenantId, userId, cancellationToken: CancellationToken.None);
var unreadCount = await _repository.GetUnreadCountAsync(_tenantId, userId, cancellationToken: CancellationToken.None);
// Assert
count.Should().Be(3);
@@ -161,11 +162,11 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
// Arrange
var userId = Guid.NewGuid();
var inbox = CreateInbox(userId, "To be archived");
await _repository.CreateAsync(inbox);
await _repository.CreateAsync(inbox, cancellationToken: CancellationToken.None);
// Act
var result = await _repository.ArchiveAsync(_tenantId, inbox.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id);
var result = await _repository.ArchiveAsync(_tenantId, inbox.Id, cancellationToken: CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id, cancellationToken: CancellationToken.None);
// Assert
result.Should().BeTrue();
@@ -180,11 +181,11 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
// Arrange
var userId = Guid.NewGuid();
var inbox = CreateInbox(userId, "To be deleted");
await _repository.CreateAsync(inbox);
await _repository.CreateAsync(inbox, cancellationToken: CancellationToken.None);
// Act
var result = await _repository.DeleteAsync(_tenantId, inbox.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id);
var result = await _repository.DeleteAsync(_tenantId, inbox.Id, cancellationToken: CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, inbox.Id, cancellationToken: CancellationToken.None);
// Assert
result.Should().BeTrue();
@@ -197,11 +198,11 @@ public sealed class InboxRepositoryTests : IAsyncLifetime
{
// Arrange - We can't easily set CreatedAt in the test, so this tests the API works
var userId = Guid.NewGuid();
await _repository.CreateAsync(CreateInbox(userId, "Recent item"));
await _repository.CreateAsync(CreateInbox(userId, "Recent item"), cancellationToken: CancellationToken.None);
// Act - Delete items older than future date (should delete the item)
var cutoff = DateTimeOffset.UtcNow.AddMinutes(1);
var count = await _repository.DeleteOldAsync(cutoff);
var count = await _repository.DeleteOldAsync(cutoff, cancellationToken: CancellationToken.None);
// Assert
count.Should().Be(1);

View File

@@ -1,12 +1,13 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
/// <summary>
/// End-to-end tests for notification delivery flow (PG-T3.10.2).
@@ -38,8 +39,8 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
_auditRepository = new NotifyAuditRepository(dataSource, NullLogger<NotifyAuditRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new ValueTask(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -55,7 +56,7 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
Config = """{"smtp_host": "smtp.example.com", "from": "noreply@example.com"}""",
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
// Create template
var template = new TemplateEntity
@@ -68,7 +69,7 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
BodyTemplate = "Your scan {{scan_name}} has completed with {{finding_count}} findings.",
Locale = "en"
};
await _templateRepository.CreateAsync(template);
await _templateRepository.CreateAsync(template, cancellationToken: CancellationToken.None);
// Create rule
var rule = new RuleEntity
@@ -83,7 +84,7 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
Enabled = true,
Priority = 100
};
await _ruleRepository.CreateAsync(rule);
await _ruleRepository.CreateAsync(rule, cancellationToken: CancellationToken.None);
// Act - Create delivery
var delivery = new DeliveryEntity
@@ -101,33 +102,33 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
CorrelationId = Guid.NewGuid().ToString(),
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(delivery);
await _deliveryRepository.CreateAsync(delivery, cancellationToken: CancellationToken.None);
// Verify pending
var pending = await _deliveryRepository.GetPendingAsync(_tenantId);
var pending = await _deliveryRepository.GetPendingAsync(_tenantId, cancellationToken: CancellationToken.None);
pending.Should().ContainSingle(d => d.Id == delivery.Id);
// Progress through lifecycle: Pending → Queued
await _deliveryRepository.MarkQueuedAsync(_tenantId, delivery.Id);
var queued = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
await _deliveryRepository.MarkQueuedAsync(_tenantId, delivery.Id, cancellationToken: CancellationToken.None);
var queued = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id, cancellationToken: CancellationToken.None);
queued!.Status.Should().Be(DeliveryStatus.Queued);
queued.QueuedAt.Should().NotBeNull();
// Queued → Sent
await _deliveryRepository.MarkSentAsync(_tenantId, delivery.Id, "smtp-msg-12345");
var sent = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
await _deliveryRepository.MarkSentAsync(_tenantId, delivery.Id, "smtp-msg-12345", cancellationToken: CancellationToken.None);
var sent = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id, cancellationToken: CancellationToken.None);
sent!.Status.Should().Be(DeliveryStatus.Sent);
sent.ExternalId.Should().Be("smtp-msg-12345");
sent.SentAt.Should().NotBeNull();
// Sent → Delivered
await _deliveryRepository.MarkDeliveredAsync(_tenantId, delivery.Id);
var delivered = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
await _deliveryRepository.MarkDeliveredAsync(_tenantId, delivery.Id, cancellationToken: CancellationToken.None);
var delivered = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id, cancellationToken: CancellationToken.None);
delivered!.Status.Should().Be(DeliveryStatus.Delivered);
delivered.DeliveredAt.Should().NotBeNull();
// Verify no longer in pending queue
var finalPending = await _deliveryRepository.GetPendingAsync(_tenantId);
var finalPending = await _deliveryRepository.GetPendingAsync(_tenantId, cancellationToken: CancellationToken.None);
finalPending.Should().NotContain(d => d.Id == delivery.Id);
}
@@ -145,7 +146,7 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
Config = """{"webhook_url": "https://hooks.slack.com/services/xxx"}""",
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
var delivery = new DeliveryEntity
{
@@ -156,13 +157,13 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
EventType = "vulnerability.detected",
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(delivery);
await _deliveryRepository.CreateAsync(delivery, cancellationToken: CancellationToken.None);
// Act - Mark as failed with retry
await _deliveryRepository.MarkFailedAsync(_tenantId, delivery.Id, "Connection refused", TimeSpan.FromMinutes(5));
await _deliveryRepository.MarkFailedAsync(_tenantId, delivery.Id, "Connection refused", TimeSpan.FromMinutes(5), cancellationToken: CancellationToken.None);
// Assert
var failed = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
var failed = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id, cancellationToken: CancellationToken.None);
failed!.Status.Should().Be(DeliveryStatus.Failed);
failed.ErrorMessage.Should().Be("Connection refused");
failed.FailedAt.Should().NotBeNull();
@@ -191,8 +192,8 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
ChannelType = ChannelType.Slack,
Enabled = true
};
await _channelRepository.CreateAsync(emailChannel);
await _channelRepository.CreateAsync(slackChannel);
await _channelRepository.CreateAsync(emailChannel, cancellationToken: CancellationToken.None);
await _channelRepository.CreateAsync(slackChannel, cancellationToken: CancellationToken.None);
var correlationId = Guid.NewGuid().ToString();
@@ -217,16 +218,16 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
CorrelationId = correlationId,
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(emailDelivery);
await _deliveryRepository.CreateAsync(slackDelivery);
await _deliveryRepository.CreateAsync(emailDelivery, cancellationToken: CancellationToken.None);
await _deliveryRepository.CreateAsync(slackDelivery, cancellationToken: CancellationToken.None);
// Act - Email succeeds, Slack fails
await _deliveryRepository.MarkSentAsync(_tenantId, emailDelivery.Id);
await _deliveryRepository.MarkDeliveredAsync(_tenantId, emailDelivery.Id);
await _deliveryRepository.MarkFailedAsync(_tenantId, slackDelivery.Id, "Rate limited");
await _deliveryRepository.MarkSentAsync(_tenantId, emailDelivery.Id, cancellationToken: CancellationToken.None);
await _deliveryRepository.MarkDeliveredAsync(_tenantId, emailDelivery.Id, cancellationToken: CancellationToken.None);
await _deliveryRepository.MarkFailedAsync(_tenantId, slackDelivery.Id, "Rate limited", cancellationToken: CancellationToken.None);
// Assert - Both tracked via correlation
var correlated = await _deliveryRepository.GetByCorrelationIdAsync(_tenantId, correlationId);
var correlated = await _deliveryRepository.GetByCorrelationIdAsync(_tenantId, correlationId, cancellationToken: CancellationToken.None);
correlated.Should().HaveCount(2);
var email = correlated.First(d => d.ChannelId == emailChannel.Id);
@@ -249,7 +250,7 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
// Create multiple deliveries in different states
// Note: DeliveryStats tracks Pending, Sent, Delivered, Failed, Bounced (no Queued)
@@ -279,13 +280,13 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
Status = status,
ErrorMessage = error
};
await _deliveryRepository.CreateAsync(d);
await _deliveryRepository.CreateAsync(d, cancellationToken: CancellationToken.None);
}
// Act
var from = DateTimeOffset.UtcNow.AddHours(-1);
var to = DateTimeOffset.UtcNow.AddHours(1);
var stats = await _deliveryRepository.GetStatsAsync(_tenantId, from, to);
var stats = await _deliveryRepository.GetStatsAsync(_tenantId, from, to, cancellationToken: CancellationToken.None);
// Assert
stats.Total.Should().Be(10);
@@ -308,7 +309,7 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
// Create multiple pending deliveries
for (int i = 0; i < 10; i++)
@@ -321,13 +322,13 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
Recipient = $"user{i}@example.com",
EventType = "test",
Status = DeliveryStatus.Pending
});
}, cancellationToken: CancellationToken.None);
}
// Act - Query multiple times
var results1 = await _deliveryRepository.GetPendingAsync(_tenantId);
var results2 = await _deliveryRepository.GetPendingAsync(_tenantId);
var results3 = await _deliveryRepository.GetPendingAsync(_tenantId);
var results1 = await _deliveryRepository.GetPendingAsync(_tenantId, cancellationToken: CancellationToken.None);
var results2 = await _deliveryRepository.GetPendingAsync(_tenantId, cancellationToken: CancellationToken.None);
var results3 = await _deliveryRepository.GetPendingAsync(_tenantId, cancellationToken: CancellationToken.None);
// Assert - Order should be deterministic
var ids1 = results1.Select(d => d.Id).ToList();
@@ -351,7 +352,7 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
await _channelRepository.CreateAsync(channel, cancellationToken: CancellationToken.None);
// Act - Record audit events
await _auditRepository.CreateAsync(new NotifyAuditEntity
@@ -362,7 +363,7 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
ResourceId = channel.Id.ToString(),
UserId = null,
Details = "{\"name\": \"audited-channel\"}"
});
}, cancellationToken: CancellationToken.None);
await _auditRepository.CreateAsync(new NotifyAuditEntity
{
@@ -371,10 +372,10 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
ResourceType = "delivery",
ResourceId = Guid.NewGuid().ToString(),
Details = "{\"recipient\": \"user@example.com\"}"
});
}, cancellationToken: CancellationToken.None);
// Assert
var audits = await _auditRepository.GetByResourceAsync(_tenantId, "channel", channel.Id.ToString());
var audits = await _auditRepository.GetByResourceAsync(_tenantId, "channel", channel.Id.ToString(), cancellationToken: CancellationToken.None);
audits.Should().ContainSingle();
audits[0].Action.Should().Be("channel.created");
}
@@ -400,11 +401,11 @@ public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
ChannelType = ChannelType.Email,
Enabled = false
};
await _channelRepository.CreateAsync(enabledChannel);
await _channelRepository.CreateAsync(disabledChannel);
await _channelRepository.CreateAsync(enabledChannel, cancellationToken: CancellationToken.None);
await _channelRepository.CreateAsync(disabledChannel, cancellationToken: CancellationToken.None);
// Act - Get all channels filtered by enabled=true
var enabled = await _channelRepository.GetAllAsync(_tenantId, enabled: true);
var enabled = await _channelRepository.GetAllAsync(_tenantId, enabled: true, cancellationToken: CancellationToken.None);
// Assert
enabled.Should().ContainSingle(c => c.Id == enabledChannel.Id);

View File

@@ -1,12 +1,12 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
[Collection(NotifyPostgresCollection.Name)]
public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
@@ -25,8 +25,8 @@ public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
_repository = new NotifyAuditRepository(dataSource, NullLogger<NotifyAuditRepository>.Instance);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private Task ResetAsync() => _fixture.ExecuteSqlAsync("TRUNCATE TABLE notify.audit, notify.deliveries, notify.digests, notify.channels RESTART IDENTITY CASCADE;");
@@ -46,7 +46,7 @@ public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
};
// Act
var id = await _repository.CreateAsync(audit);
var id = await _repository.CreateAsync(audit, CancellationToken.None);
// Assert
id.Should().BeGreaterThan(0);
@@ -60,12 +60,12 @@ public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
await ResetAsync();
var audit1 = CreateAudit("action1");
var audit2 = CreateAudit("action2");
await _repository.CreateAsync(audit1);
await Task.Delay(10);
await _repository.CreateAsync(audit2);
await _repository.CreateAsync(audit1, CancellationToken.None);
await Task.Delay(10, CancellationToken.None);
await _repository.CreateAsync(audit2, CancellationToken.None);
// Act
var audits = await _repository.ListAsync(_tenantId, limit: 10);
var audits = await _repository.ListAsync(_tenantId, limit: 10, cancellationToken: CancellationToken.None);
// Assert
audits.Should().HaveCount(2);
@@ -86,10 +86,10 @@ public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
ResourceType = "rule",
ResourceId = resourceId
};
await _repository.CreateAsync(audit);
await _repository.CreateAsync(audit, CancellationToken.None);
// Act
var audits = await _repository.GetByResourceAsync(_tenantId, "rule", resourceId);
var audits = await _repository.GetByResourceAsync(_tenantId, "rule", resourceId, cancellationToken: CancellationToken.None);
// Assert
audits.Should().HaveCount(1);
@@ -108,17 +108,17 @@ public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
Action = "template.created",
ResourceType = "template",
ResourceId = Guid.NewGuid().ToString()
});
}, CancellationToken.None);
await _repository.CreateAsync(new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "template.updated",
ResourceType = "template",
ResourceId = Guid.NewGuid().ToString()
});
}, CancellationToken.None);
// Act
var audits = await _repository.GetByResourceAsync(_tenantId, "template");
var audits = await _repository.GetByResourceAsync(_tenantId, "template", cancellationToken: CancellationToken.None);
// Assert
audits.Should().HaveCount(2);
@@ -145,11 +145,11 @@ public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
ResourceType = "delivery",
CorrelationId = correlationId
};
await _repository.CreateAsync(audit1);
await _repository.CreateAsync(audit2);
await _repository.CreateAsync(audit1, CancellationToken.None);
await _repository.CreateAsync(audit2, CancellationToken.None);
// Act
var audits = await _repository.GetByCorrelationIdAsync(_tenantId, correlationId);
var audits = await _repository.GetByCorrelationIdAsync(_tenantId, correlationId, CancellationToken.None);
// Assert
audits.Should().HaveCount(2);
@@ -162,11 +162,11 @@ public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
{
// Arrange
await ResetAsync();
await _repository.CreateAsync(CreateAudit("old-action"));
await _repository.CreateAsync(CreateAudit("old-action"), CancellationToken.None);
// Act - Delete audits older than future date
var cutoff = DateTimeOffset.UtcNow.AddMinutes(1);
var count = await _repository.DeleteOldAsync(cutoff);
var count = await _repository.DeleteOldAsync(cutoff, CancellationToken.None);
// Assert
count.Should().Be(1);

View File

@@ -13,14 +13,14 @@ using StellaOps.TestKit;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
/// <summary>
/// Migration tests for Notify.Storage.
/// Implements Model S1 (Storage/Postgres) migration test requirements:
/// - Apply all migrations from scratch (fresh database)
/// - Apply migrations from N-1 (incremental application)
/// - Verify migration idempotency (apply twice no error)
/// - Verify migration idempotency (apply twice -> no error)
/// </summary>
[Trait("Category", TestCategories.Integration)]
[Trait("Category", "StorageMigration")]
@@ -28,7 +28,7 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
{
private PostgreSqlContainer _container = null!;
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
@@ -37,10 +37,10 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
.WithPassword("postgres")
.Build();
await _container.StartAsync();
await _container.StartAsync(CancellationToken.None);
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
@@ -52,11 +52,11 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
var connectionString = _container.GetConnectionString();
// Act - Apply all migrations from scratch
await ApplyAllMigrationsAsync(connectionString);
await ApplyAllMigrationsAsync(connectionString, CancellationToken.None);
// Assert - Verify Notify tables exist
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(CancellationToken.None);
var tables = await connection.QueryAsync<string>(
@"SELECT table_name FROM information_schema.tables
@@ -75,11 +75,11 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
await ApplyAllMigrationsAsync(connectionString, CancellationToken.None);
// Assert - Verify migrations are recorded
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(CancellationToken.None);
var migrationsApplied = await connection.QueryAsync<string>(
"SELECT migration_id FROM __migrations ORDER BY applied_at");
@@ -95,8 +95,8 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
var connectionString = _container.GetConnectionString();
// Act - Apply migrations twice
await ApplyAllMigrationsAsync(connectionString);
var applyAgain = async () => await ApplyAllMigrationsAsync(connectionString);
await ApplyAllMigrationsAsync(connectionString, CancellationToken.None);
var applyAgain = async () => await ApplyAllMigrationsAsync(connectionString, CancellationToken.None);
// Assert - Second application should not throw
await applyAgain.Should().NotThrowAsync(
@@ -104,7 +104,7 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
// Verify migrations are not duplicated
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(CancellationToken.None);
var migrationCount = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM __migrations");
@@ -122,11 +122,11 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
await ApplyAllMigrationsAsync(connectionString, CancellationToken.None);
// Assert - Verify indexes exist
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(CancellationToken.None);
var indexes = await connection.QueryAsync<string>(
@"SELECT indexname FROM pg_indexes
@@ -147,7 +147,7 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
var migrationFiles = GetMigrationFiles();
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(CancellationToken.None);
// Create migration tracking table first
await connection.ExecuteAsync(@"
@@ -195,11 +195,11 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
await ApplyAllMigrationsAsync(connectionString, CancellationToken.None);
// Assert - Verify foreign key constraints exist and are valid
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(CancellationToken.None);
var foreignKeys = await connection.QueryAsync<string>(
@"SELECT tc.constraint_name
@@ -218,11 +218,11 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
await ApplyAllMigrationsAsync(connectionString, CancellationToken.None);
// Assert - Verify deliveries table has required columns
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(CancellationToken.None);
var columns = await connection.QueryAsync<string>(
@"SELECT column_name FROM information_schema.columns
@@ -244,11 +244,11 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
{
// Arrange
var connectionString = _container.GetConnectionString();
await ApplyAllMigrationsAsync(connectionString);
await ApplyAllMigrationsAsync(connectionString, CancellationToken.None);
// Assert - Verify notify schema exists
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(CancellationToken.None);
var schemaExists = await connection.ExecuteScalarAsync<int>(
@"SELECT COUNT(*) FROM information_schema.schemata
@@ -257,10 +257,10 @@ public sealed class NotifyMigrationTests : IAsyncLifetime
schemaExists.Should().Be(1, "notify schema should exist");
}
private async Task ApplyAllMigrationsAsync(string connectionString)
private async Task ApplyAllMigrationsAsync(string connectionString, CancellationToken cancellationToken)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await connection.OpenAsync(cancellationToken);
// Create migration tracking table
await connection.ExecuteAsync(@"

View File

@@ -7,14 +7,14 @@
using System.Reflection;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Notify.Storage.Postgres;
using StellaOps.Notify.Persistence.Postgres;
using Xunit;
// Type aliases to disambiguate TestKit and Infrastructure.Postgres.Testing fixtures
using TestKitPostgresFixture = StellaOps.TestKit.Fixtures.PostgresFixture;
using TestKitPostgresIsolationMode = StellaOps.TestKit.Fixtures.PostgresIsolationMode;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the Notify module.
@@ -50,14 +50,14 @@ public sealed class NotifyTestKitPostgresFixture : IAsyncLifetime
public TestKitPostgresFixture Fixture => _fixture;
public string ConnectionString => _fixture.ConnectionString;
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
_fixture = new TestKitPostgresFixture(TestKitPostgresIsolationMode.Truncation);
_fixture = new TestKitPostgresFixture { IsolationMode = TestKitPostgresIsolationMode.Truncation };
await _fixture.InitializeAsync();
await _fixture.ApplyMigrationsFromAssemblyAsync(MigrationAssembly);
await _fixture.ApplyMigrationsFromAssemblyAsync(MigrationAssembly, "notify");
}
public Task DisposeAsync() => _fixture.DisposeAsync();
public ValueTask DisposeAsync() => new ValueTask(_fixture.DisposeAsync());
public Task TruncateAllTablesAsync() => _fixture.TruncateAllTablesAsync();
}

View File

@@ -8,12 +8,12 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
/// <summary>
/// Retry state persistence tests for Notify delivery storage operations.
@@ -39,7 +39,7 @@ public sealed class RetryStatePersistenceTests : IAsyncLifetime
_fixture = fixture;
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await _fixture.ExecuteSqlAsync(
"TRUNCATE TABLE notify.audit, notify.deliveries, notify.digests, notify.channels RESTART IDENTITY CASCADE;");
@@ -62,7 +62,7 @@ public sealed class RetryStatePersistenceTests : IAsyncLifetime
});
}
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task MarkFailed_WithRetry_SavesNextRetryTime()

View File

@@ -1,12 +1,13 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
[Collection(NotifyPostgresCollection.Name)]
public sealed class RuleRepositoryTests : IAsyncLifetime
@@ -25,8 +26,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
_repository = new RuleRepository(dataSource, NullLogger<RuleRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new ValueTask(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -46,8 +47,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
};
// Act
await _repository.CreateAsync(rule);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id);
await _repository.CreateAsync(rule, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id, CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -63,10 +64,10 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
{
// Arrange
var rule = CreateRule("info-digest");
await _repository.CreateAsync(rule);
await _repository.CreateAsync(rule, CancellationToken.None);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "info-digest");
var fetched = await _repository.GetByNameAsync(_tenantId, "info-digest", CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -80,11 +81,11 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
// Arrange
var rule1 = CreateRule("rule1");
var rule2 = CreateRule("rule2");
await _repository.CreateAsync(rule1);
await _repository.CreateAsync(rule2);
await _repository.CreateAsync(rule1, CancellationToken.None);
await _repository.CreateAsync(rule2, CancellationToken.None);
// Act
var rules = await _repository.ListAsync(_tenantId);
var rules = await _repository.ListAsync(_tenantId, cancellationToken: CancellationToken.None);
// Assert
rules.Should().HaveCount(2);
@@ -105,11 +106,11 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
Enabled = false,
EventTypes = ["test"]
};
await _repository.CreateAsync(enabledRule);
await _repository.CreateAsync(disabledRule);
await _repository.CreateAsync(enabledRule, CancellationToken.None);
await _repository.CreateAsync(disabledRule, CancellationToken.None);
// Act
var enabledRules = await _repository.ListAsync(_tenantId, enabled: true);
var enabledRules = await _repository.ListAsync(_tenantId, enabled: true, cancellationToken: CancellationToken.None);
// Assert
enabledRules.Should().HaveCount(1);
@@ -137,11 +138,11 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
Enabled = true,
EventTypes = ["vulnerability.found"]
};
await _repository.CreateAsync(scanRule);
await _repository.CreateAsync(vulnRule);
await _repository.CreateAsync(scanRule, CancellationToken.None);
await _repository.CreateAsync(vulnRule, CancellationToken.None);
// Act
var matchingRules = await _repository.GetMatchingRulesAsync(_tenantId, "scan.completed");
var matchingRules = await _repository.GetMatchingRulesAsync(_tenantId, "scan.completed", CancellationToken.None);
// Assert
matchingRules.Should().HaveCount(1);
@@ -154,7 +155,7 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
{
// Arrange
var rule = CreateRule("update-test");
await _repository.CreateAsync(rule);
await _repository.CreateAsync(rule, CancellationToken.None);
// Act
var updated = new RuleEntity
@@ -167,8 +168,8 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
Enabled = false,
EventTypes = ["new.event"]
};
var result = await _repository.UpdateAsync(updated);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id);
var result = await _repository.UpdateAsync(updated, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id, CancellationToken.None);
// Assert
result.Should().BeTrue();
@@ -183,11 +184,11 @@ public sealed class RuleRepositoryTests : IAsyncLifetime
{
// Arrange
var rule = CreateRule("delete-test");
await _repository.CreateAsync(rule);
await _repository.CreateAsync(rule, CancellationToken.None);
// Act
var result = await _repository.DeleteAsync(_tenantId, rule.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id);
var result = await _repository.DeleteAsync(_tenantId, rule.Id, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, rule.Id, CancellationToken.None);
// Assert
result.Should().BeTrue();

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Notify.Persistence.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Notify.Persistence\StellaOps.Notify.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,12 +1,13 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
namespace StellaOps.Notify.Persistence.Postgres.Tests;
[Collection(NotifyPostgresCollection.Name)]
public sealed class TemplateRepositoryTests : IAsyncLifetime
@@ -25,8 +26,8 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
_repository = new TemplateRepository(dataSource, NullLogger<TemplateRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => new ValueTask(_fixture.TruncateAllTablesAsync());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -45,8 +46,8 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
};
// Act
await _repository.CreateAsync(template);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id);
await _repository.CreateAsync(template, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id, CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -62,10 +63,10 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
{
// Arrange
var template = CreateTemplate("alert-template", ChannelType.Slack);
await _repository.CreateAsync(template);
await _repository.CreateAsync(template, CancellationToken.None);
// Act
var fetched = await _repository.GetByNameAsync(_tenantId, "alert-template", ChannelType.Slack);
var fetched = await _repository.GetByNameAsync(_tenantId, "alert-template", ChannelType.Slack, cancellationToken: CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
@@ -92,18 +93,18 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
TenantId = _tenantId,
Name = "localized-template",
ChannelType = ChannelType.Email,
BodyTemplate = "Contenu français",
BodyTemplate = "Contenu francais",
Locale = "fr"
};
await _repository.CreateAsync(enTemplate);
await _repository.CreateAsync(frTemplate);
await _repository.CreateAsync(enTemplate, CancellationToken.None);
await _repository.CreateAsync(frTemplate, CancellationToken.None);
// Act
var frFetched = await _repository.GetByNameAsync(_tenantId, "localized-template", ChannelType.Email, "fr");
var frFetched = await _repository.GetByNameAsync(_tenantId, "localized-template", ChannelType.Email, "fr", CancellationToken.None);
// Assert
frFetched.Should().NotBeNull();
frFetched!.BodyTemplate.Should().Contain("français");
frFetched!.BodyTemplate.Should().Contain("francais");
}
[Trait("Category", TestCategories.Unit)]
@@ -113,11 +114,11 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
// Arrange
var template1 = CreateTemplate("template1", ChannelType.Email);
var template2 = CreateTemplate("template2", ChannelType.Slack);
await _repository.CreateAsync(template1);
await _repository.CreateAsync(template2);
await _repository.CreateAsync(template1, CancellationToken.None);
await _repository.CreateAsync(template2, CancellationToken.None);
// Act
var templates = await _repository.ListAsync(_tenantId);
var templates = await _repository.ListAsync(_tenantId, cancellationToken: CancellationToken.None);
// Assert
templates.Should().HaveCount(2);
@@ -131,11 +132,11 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
// Arrange
var emailTemplate = CreateTemplate("email", ChannelType.Email);
var slackTemplate = CreateTemplate("slack", ChannelType.Slack);
await _repository.CreateAsync(emailTemplate);
await _repository.CreateAsync(slackTemplate);
await _repository.CreateAsync(emailTemplate, CancellationToken.None);
await _repository.CreateAsync(slackTemplate, CancellationToken.None);
// Act
var emailTemplates = await _repository.ListAsync(_tenantId, channelType: ChannelType.Email);
var emailTemplates = await _repository.ListAsync(_tenantId, channelType: ChannelType.Email, cancellationToken: CancellationToken.None);
// Assert
emailTemplates.Should().HaveCount(1);
@@ -148,7 +149,7 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
{
// Arrange
var template = CreateTemplate("update-test", ChannelType.Email);
await _repository.CreateAsync(template);
await _repository.CreateAsync(template, CancellationToken.None);
// Act
var updated = new TemplateEntity
@@ -160,8 +161,8 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
SubjectTemplate = "Updated Subject",
BodyTemplate = "Updated body content"
};
var result = await _repository.UpdateAsync(updated);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id);
var result = await _repository.UpdateAsync(updated, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id, CancellationToken.None);
// Assert
result.Should().BeTrue();
@@ -175,11 +176,11 @@ public sealed class TemplateRepositoryTests : IAsyncLifetime
{
// Arrange
var template = CreateTemplate("delete-test", ChannelType.Email);
await _repository.CreateAsync(template);
await _repository.CreateAsync(template, CancellationToken.None);
// Act
var result = await _repository.DeleteAsync(_tenantId, template.Id);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id);
var result = await _repository.DeleteAsync(_tenantId, template.Id, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(_tenantId, template.Id, CancellationToken.None);
// Assert
result.Should().BeTrue();

View File

@@ -5,7 +5,6 @@ using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
@@ -15,29 +14,29 @@ using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private readonly IContainer _nats;
private string? _skipReason;
public NatsNotifyDeliveryQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
_nats = new ContainerBuilder()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-delivery-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Server is ready"))
.Build();
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
try
{
@@ -49,7 +48,7 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
}
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
@@ -77,10 +76,10 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
channelId: "chan-a",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
var first = await queue.PublishAsync(message, CancellationToken.None);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
var second = await queue.PublishAsync(message, CancellationToken.None);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
@@ -100,16 +99,16 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-retry",
channelType: NotifyChannelType.Teams));
channelType: NotifyChannelType.Teams), CancellationToken.None);
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)), CancellationToken.None)).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry, CancellationToken.None);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)), CancellationToken.None)).Single();
retried.Attempt.Should().BeGreaterThan(lease.Attempt);
await retried.AcknowledgeAsync();
await retried.AcknowledgeAsync(CancellationToken.None);
}
[Trait("Category", TestCategories.Unit)]
@@ -133,18 +132,17 @@ public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-dead",
channelType: NotifyChannelType.Webhook));
channelType: NotifyChannelType.Webhook), CancellationToken.None);
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)), CancellationToken.None)).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry, CancellationToken.None);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)), CancellationToken.None)).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry, CancellationToken.None);
await Task.Delay(200);
await Task.Delay(200, CancellationToken.None);
await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! });
using StellaOps.TestKit;
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
@@ -155,14 +153,14 @@ using StellaOps.TestKit;
AckPolicy = ConsumerConfigAckPolicy.Explicit
};
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig);
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig, CancellationToken.None);
var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) };
NatsJSMsg<byte[]>? dlqMsg = null;
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.Default, fetchOpts))
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.Default, fetchOpts, CancellationToken.None))
{
dlqMsg = msg;
await msg.AckAsync(new AckOpts());
await msg.AckAsync(new AckOpts(), CancellationToken.None);
break;
}

View File

@@ -5,36 +5,35 @@ using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private readonly IContainer _nats;
private string? _skipReason;
public NatsNotifyEventQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
_nats = new ContainerBuilder()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-tests-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Server is ready"))
.Build();
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
try
{
@@ -46,7 +45,7 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
}
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
@@ -74,10 +73,10 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
options.Nats.Subject,
traceId: "trace-1");
var first = await queue.PublishAsync(message);
var first = await queue.PublishAsync(message, CancellationToken.None);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
var second = await queue.PublishAsync(message, CancellationToken.None);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
@@ -101,9 +100,9 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
traceId: "trace-xyz",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
await queue.PublishAsync(message, CancellationToken.None);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)), CancellationToken.None);
leases.Should().ContainSingle();
var lease = leases[0];
@@ -112,9 +111,9 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
lease.TraceId.Should().Be("trace-xyz");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
await lease.AcknowledgeAsync(CancellationToken.None);
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)));
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)), CancellationToken.None);
afterAck.Should().BeEmpty();
}
@@ -133,10 +132,10 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
var first = TestData.CreateEvent();
var second = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject));
await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject));
await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject), CancellationToken.None);
await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject), CancellationToken.None);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2)));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2)), CancellationToken.None);
leases.Should().HaveCount(2);
leases.Select(x => x.Message.Event.EventId)
@@ -156,23 +155,22 @@ public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
var options = CreateOptions();
await using var queue = CreateQueue(options);
using StellaOps.TestKit;
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject));
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject), CancellationToken.None);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)), CancellationToken.None);
leases.Should().ContainSingle();
await Task.Delay(200);
await Task.Delay(200, CancellationToken.None);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)));
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)), CancellationToken.None);
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
await lease.AcknowledgeAsync(CancellationToken.None);
}
private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)

View File

@@ -5,7 +5,6 @@ using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
@@ -13,29 +12,34 @@ using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private readonly IContainer _redis;
private string? _skipReason;
private string _connectionString = string.Empty;
public RedisNotifyDeliveryQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
_redis = new ContainerBuilder()
.WithImage("redis:7-alpine")
.WithCleanUp(true)
.WithName($"redis-notify-delivery-tests-{Guid.NewGuid():N}")
.WithPortBinding(6379, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("redis-cli", "ping"))
.Build();
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
try
{
await _redis.StartAsync();
_connectionString = $"{_redis.Hostname}:{_redis.GetMappedPublicPort(6379)}";
}
catch (Exception ex)
{
@@ -43,14 +47,14 @@ public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
}
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
await _redis.DisposeAsync().ConfigureAwait(false);
}
[Trait("Category", TestCategories.Unit)]
@@ -71,10 +75,10 @@ public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
channelId: "channel-1",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
var first = await queue.PublishAsync(message, CancellationToken.None);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
var second = await queue.PublishAsync(message, CancellationToken.None);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
@@ -94,17 +98,17 @@ public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-retry",
channelType: NotifyChannelType.Teams));
channelType: NotifyChannelType.Teams), CancellationToken.None);
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)), CancellationToken.None)).Single();
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry, CancellationToken.None);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)), CancellationToken.None)).Single();
retried.Attempt.Should().Be(2);
await retried.AcknowledgeAsync();
await retried.AcknowledgeAsync(CancellationToken.None);
}
[Trait("Category", TestCategories.Unit)]
@@ -124,21 +128,20 @@ public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
await using var queue = CreateQueue(options);
using StellaOps.TestKit;
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-dead",
channelType: NotifyChannelType.Email));
channelType: NotifyChannelType.Email), CancellationToken.None);
var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)), CancellationToken.None)).Single();
await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry, CancellationToken.None);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)), CancellationToken.None)).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry, CancellationToken.None);
await Task.Delay(100);
await Task.Delay(100, CancellationToken.None);
var mux = await ConnectionMultiplexer.ConnectAsync(_redis.ConnectionString);
var mux = await ConnectionMultiplexer.ConnectAsync(_connectionString);
var db = mux.GetDatabase();
var deadLetters = await db.StreamReadAsync(options.Redis.DeadLetterStreamName, "0-0");
deadLetters.Should().NotBeEmpty();
@@ -166,7 +169,7 @@ using StellaOps.TestKit;
ClaimIdleThreshold = TimeSpan.FromSeconds(1),
Redis = new NotifyRedisDeliveryQueueOptions
{
ConnectionString = _redis.ConnectionString,
ConnectionString = _connectionString,
StreamName = "notify:deliveries:test",
ConsumerGroup = "notify-delivery-tests",
IdempotencyKeyPrefix = "notify:deliveries:test:idemp:"

View File

@@ -6,7 +6,6 @@ using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
@@ -14,29 +13,34 @@ using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private readonly IContainer _redis;
private string? _skipReason;
private string _connectionString = string.Empty;
public RedisNotifyEventQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
_redis = new ContainerBuilder()
.WithImage("redis:7-alpine")
.WithCleanUp(true)
.WithName($"redis-notify-event-tests-{Guid.NewGuid():N}")
.WithPortBinding(6379, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("redis-cli", "ping"))
.Build();
}
public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
try
{
await _redis.StartAsync();
_connectionString = $"{_redis.Hostname}:{_redis.GetMappedPublicPort(6379)}";
}
catch (Exception ex)
{
@@ -44,14 +48,14 @@ public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
}
}
public async Task DisposeAsync()
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
await _redis.DisposeAsync().ConfigureAwait(false);
}
[Trait("Category", TestCategories.Unit)]
@@ -69,10 +73,10 @@ public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
var notifyEvent = TestData.CreateEvent(tenant: "tenant-a");
var message = new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream);
var first = await queue.PublishAsync(message);
var first = await queue.PublishAsync(message, CancellationToken.None);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
var second = await queue.PublishAsync(message, CancellationToken.None);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
@@ -96,9 +100,9 @@ public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
traceId: "trace-123",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
await queue.PublishAsync(message, CancellationToken.None);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)), CancellationToken.None);
leases.Should().ContainSingle();
var lease = leases[0];
@@ -107,9 +111,9 @@ public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
lease.TraceId.Should().Be("trace-123");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
await lease.AcknowledgeAsync(CancellationToken.None);
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)), CancellationToken.None);
afterAck.Should().BeEmpty();
}
@@ -129,10 +133,10 @@ public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
var firstEvent = TestData.CreateEvent();
var secondEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream));
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream));
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream), CancellationToken.None);
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream), CancellationToken.None);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)), CancellationToken.None);
leases.Should().HaveCount(2);
leases.Select(l => l.Message.Event.EventId)
@@ -152,24 +156,23 @@ public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
var options = CreateOptions();
await using var queue = CreateQueue(options);
using StellaOps.TestKit;
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream));
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream), CancellationToken.None);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)), CancellationToken.None);
leases.Should().ContainSingle();
// Ensure the message has been pending long enough for claim.
await Task.Delay(50);
await Task.Delay(50, CancellationToken.None);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero));
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero), CancellationToken.None);
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
await lease.AcknowledgeAsync(CancellationToken.None);
}
private RedisNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
@@ -194,7 +197,7 @@ using StellaOps.TestKit;
var redisOptions = new NotifyRedisEventQueueOptions
{
ConnectionString = _redis.ConnectionString,
ConnectionString = _connectionString,
Streams = new List<NotifyRedisEventStreamOptions> { streamOptions }
};

View File

@@ -1,25 +1,27 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="DotNet.Testcontainers" Version="1.7.0-beta.2269" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Testcontainers" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Notify.Storage.Postgres\StellaOps.Notify.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -13,7 +13,8 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.WebService.Tests;
@@ -76,9 +77,9 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
handler.ValidateToken(token, parameters, out _);
}
public Task InitializeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -188,7 +189,7 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
""")!;
var acquireResponse = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
var acquireContent = JsonNode.Parse(await acquireResponse.Content.ReadAsStringAsync());
var acquireContent = JsonNode.Parse(await acquireResponse.Content.ReadAsStringAsync(CancellationToken.None));
Assert.True(acquireContent? ["acquired"]?.GetValue<bool>());
await PostAsync(client, "/api/v1/notify/locks/release", JsonNode.Parse("""
@@ -199,7 +200,7 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
""")!);
var secondAcquire = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
var secondContent = JsonNode.Parse(await secondAcquire.Content.ReadAsStringAsync());
var secondContent = JsonNode.Parse(await secondAcquire.Content.ReadAsStringAsync(CancellationToken.None));
Assert.True(secondContent? ["acquired"]?.GetValue<bool>());
}
@@ -226,7 +227,7 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
var response = await PostAsync(client, "/api/v1/notify/channels/channel-test/test", payload);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(CancellationToken.None))!.AsObject();
Assert.Equal("tenant-web", json["tenantId"]?.GetValue<string>());
Assert.Equal("channel-test", json["channelId"]?.GetValue<string>());
Assert.NotNull(json["queuedAt"]);
@@ -298,7 +299,6 @@ public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Pro
builder.ConfigureServices(services =>
{
services.AddSingleton<INotifyChannelTestProvider, FakeSlackTestProvider>();
using StellaOps.TestKit;
});
});
@@ -321,7 +321,7 @@ using StellaOps.TestKit;
var response = await PostAsync(client, "/api/v1/notify/channels/channel-provider/test", payload);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(CancellationToken.None))!.AsObject();
var preview = json["preview"]?.AsObject();
Assert.NotNull(preview);
Assert.Equal("#ops-alerts", preview?["target"]?.GetValue<string>());
@@ -379,7 +379,7 @@ using StellaOps.TestKit;
{
var response = await SendAsync(client, HttpMethod.Get, path, useOperatorToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
return JsonNode.Parse(content) as JsonArray;
}
@@ -387,7 +387,7 @@ using StellaOps.TestKit;
{
var response = await SendAsync(client, HttpMethod.Get, path, useOperatorToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
return JsonNode.Parse(content) as JsonObject;
}
@@ -401,7 +401,7 @@ using StellaOps.TestKit;
var response = await SendAsync(client, request, useOperatorToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
var body = await response.Content.ReadAsStringAsync(CancellationToken.None);
var authHeader = response.Headers.WwwAuthenticate.ToString();
throw new InvalidOperationException($"Request to {path} failed with {(int)response.StatusCode} {response.StatusCode}: {body} (WWW-Authenticate: {authHeader})");
}
@@ -425,7 +425,7 @@ using StellaOps.TestKit;
{
request.Headers.Add("X-StellaOps-Tenant", "tenant-web");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", useOperatorToken ? _operatorToken : _viewerToken);
return client.SendAsync(request);
return client.SendAsync(request, CancellationToken.None);
}
private static string CreateToken(params string[] scopes)

View File

@@ -1,6 +1,8 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
using Xunit.v3;
using StellaOps.TestKit;
namespace StellaOps.Notify.WebService.Tests;
@@ -22,9 +24,9 @@ public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactor
});
}
public Task InitializeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
@@ -34,10 +36,10 @@ public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactor
var payload = LoadSampleNode("notify-rule@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/rules/normalize", payload);
var response = await client.PostAsJsonAsync("/internal/notify/rules/normalize", payload, CancellationToken.None);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.rule@1", normalized?["schemaVersion"]?.GetValue<string>());
@@ -51,10 +53,10 @@ public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactor
var payload = LoadSampleNode("notify-channel@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/channels/normalize", payload);
var response = await client.PostAsJsonAsync("/internal/notify/channels/normalize", payload, CancellationToken.None);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.channel@1", normalized?["schemaVersion"]?.GetValue<string>());
@@ -68,10 +70,10 @@ public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactor
var payload = LoadSampleNode("notify-template@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/templates/normalize", payload);
var response = await client.PostAsJsonAsync("/internal/notify/templates/normalize", payload, CancellationToken.None);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.template@1", normalized?["schemaVersion"]?.GetValue<string>());

View File

@@ -1,11 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup> <ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
@@ -14,13 +17,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup>
@@ -28,4 +27,4 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
</Project>

View File

@@ -19,6 +19,7 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.IdentityModel.Tokens;
using Xunit;
using Xunit.v3;
namespace StellaOps.Notify.WebService.Tests.W1;
@@ -54,8 +55,8 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
});
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
#region Deny-by-Default
@@ -67,7 +68,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// No Authorization header
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
@@ -80,7 +81,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/healthz");
var response = await client.GetAsync("/healthz", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -94,7 +95,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token-here");
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
@@ -108,7 +109,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "NotBearer some-token");
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
@@ -131,7 +132,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", expiredToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
@@ -150,7 +151,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", notYetValidToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
@@ -169,7 +170,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", validToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -188,7 +189,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -207,7 +208,8 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Act
var response = await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
@@ -226,7 +228,8 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Act
var response = await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -245,10 +248,11 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
var payload = CreateRulePayload(ruleId);
await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Act
var response = await client.DeleteAsync($"/api/v1/notify/rules/{ruleId}");
var response = await client.DeleteAsync($"/api/v1/notify/rules/{ruleId}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
@@ -263,7 +267,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongScopeToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
@@ -282,7 +286,8 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Act - internal normalize endpoint
var response = await client.PostAsync(
"/api/v1/notify/_internal/rules/normalize",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -307,11 +312,12 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
// Act
await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Get the rule back
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}");
var content = await response.Content.ReadAsStringAsync();
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}", CancellationToken.None);
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
// Assert - tenantId should match token, not payload
@@ -344,13 +350,13 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
new StringContent(CreateRulePayload(rule2Id).ToJsonString(), Encoding.UTF8, "application/json"));
// Act - tenant 1 lists rules
var response1 = await client1.GetAsync("/api/v1/notify/rules");
var content1 = await response1.Content.ReadAsStringAsync();
var response1 = await client1.GetAsync("/api/v1/notify/rules", CancellationToken.None);
var content1 = await response1.Content.ReadAsStringAsync(CancellationToken.None);
var rules1 = JsonNode.Parse(content1)?.AsArray();
// Act - tenant 2 lists rules
var response2 = await client2.GetAsync("/api/v1/notify/rules");
var content2 = await response2.Content.ReadAsStringAsync();
var response2 = await client2.GetAsync("/api/v1/notify/rules", CancellationToken.None);
var content2 = await response2.Content.ReadAsStringAsync(CancellationToken.None);
var rules2 = JsonNode.Parse(content2)?.AsArray();
// Assert - each tenant only sees their own rules
@@ -409,7 +415,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
// Verify rule still exists for tenant 1
var verifyResponse = await client1.GetAsync($"/api/v1/notify/rules/{ruleId}");
var verifyResponse = await client1.GetAsync($"/api/v1/notify/rules/{ruleId}", CancellationToken.None);
verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
@@ -432,7 +438,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongIssuerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
@@ -453,7 +459,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongAudienceToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
@@ -474,7 +480,7 @@ public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongKeyToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);

View File

@@ -20,6 +20,7 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.IdentityModel.Tokens;
using Xunit;
using Xunit.v3;
namespace StellaOps.Notify.WebService.Tests.W1;
@@ -61,8 +62,8 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
_adminToken = CreateToken("notify.viewer", "notify.operator", "notify.admin");
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
#region Health Endpoints Contract
@@ -73,11 +74,11 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/healthz");
var response = await client.GetAsync("/healthz", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json?["status"]?.GetValue<string>().Should().Be("ok");
}
@@ -89,7 +90,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/readyz");
var response = await client.GetAsync("/readyz", CancellationToken.None);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable);
@@ -107,13 +108,13 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/json");
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json.Should().NotBeNull();
json!.AsArray().Should().NotBeNull();
@@ -132,7 +133,8 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
// Act
var response = await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -166,7 +168,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules/nonexistent-rule-id");
var response = await client.GetAsync("/api/v1/notify/rules/nonexistent-rule-id", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -184,10 +186,11 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
var payload = CreateRulePayload(ruleId);
await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Act
var response = await client.DeleteAsync($"/api/v1/notify/rules/{ruleId}");
var response = await client.DeleteAsync($"/api/v1/notify/rules/{ruleId}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
@@ -205,13 +208,13 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/channels");
var response = await client.GetAsync("/api/v1/notify/channels", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/json");
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json.Should().NotBeNull();
json!.AsArray().Should().NotBeNull();
@@ -230,7 +233,8 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
// Act
var response = await client.PostAsync(
"/api/v1/notify/channels",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -248,11 +252,11 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/templates");
var response = await client.GetAsync("/api/v1/notify/templates", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json.Should().NotBeNull();
json!.AsArray().Should().NotBeNull();
@@ -271,7 +275,8 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
// Act
var response = await client.PostAsync(
"/api/v1/notify/templates",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -294,7 +299,8 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
// Act
var response = await client.PostAsync(
"/api/v1/notify/deliveries",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert - can be 201 Created or 202 Accepted depending on processing
response.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.Accepted);
@@ -308,11 +314,11 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/deliveries?limit=10");
var response = await client.GetAsync("/api/v1/notify/deliveries?limit=10", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json.Should().NotBeNull();
json!.AsArray().Should().NotBeNull();
@@ -326,7 +332,7 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
// Act
var response = await client.GetAsync($"/api/v1/notify/deliveries/{Guid.NewGuid()}");
var response = await client.GetAsync($"/api/v1/notify/deliveries/{Guid.NewGuid()}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -348,11 +354,12 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
// Act
var response = await client.PostAsync(
"/api/v1/notify/_internal/rules/normalize",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json?["schemaVersion"]?.GetValue<string>().Should().NotBeNullOrEmpty();
}
@@ -372,15 +379,16 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
var payload = CreateRulePayload(ruleId);
await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Act
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}");
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
// Verify required fields exist
@@ -403,15 +411,16 @@ public class NotifyWebServiceContractTests : IClassFixture<WebApplicationFactory
var payload = CreateChannelPayload(channelId);
await client.PostAsync(
"/api/v1/notify/channels",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Act
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _viewerToken);
var response = await client.GetAsync($"/api/v1/notify/channels/{channelId}");
var response = await client.GetAsync($"/api/v1/notify/channels/{channelId}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var content = await response.Content.ReadAsStringAsync(CancellationToken.None);
var json = JsonNode.Parse(content);
json?["channelId"].Should().NotBeNull();

View File

@@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Xunit;
using Xunit.v3;
namespace StellaOps.Notify.WebService.Tests.W1;
@@ -70,16 +71,16 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
ActivitySource.AddActivityListener(_activityListener);
}
public Task InitializeAsync()
public ValueTask InitializeAsync()
{
_capturedActivities.Clear();
return Task.CompletedTask;
return ValueTask.CompletedTask;
}
public Task DisposeAsync()
public ValueTask DisposeAsync()
{
_activityListener.Dispose();
return Task.CompletedTask;
return ValueTask.CompletedTask;
}
#region Rule Operations Tracing
@@ -99,7 +100,8 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
// Act
var response = await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -128,12 +130,13 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var payload = CreateRulePayload(ruleId);
await client.PostAsync(
"/api/v1/notify/rules",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
_capturedActivities.Clear();
// Act
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}");
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -159,7 +162,8 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
// Act
var response = await client.PostAsync(
"/api/v1/notify/channels",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -176,7 +180,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/channels");
var response = await client.GetAsync("/api/v1/notify/channels", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -202,7 +206,8 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
// Act
var response = await client.PostAsync(
"/api/v1/notify/deliveries",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert - delivery might return 201 or 202
response.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.Accepted);
@@ -223,12 +228,13 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
var payload = CreateDeliveryPayload(deliveryId, "test@example.com", "email:default");
await client.PostAsync(
"/api/v1/notify/deliveries",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
_capturedActivities.Clear();
// Act
var response = await client.GetAsync($"/api/v1/notify/deliveries/{deliveryId}");
var response = await client.GetAsync($"/api/v1/notify/deliveries/{deliveryId}", CancellationToken.None);
// Assert - either OK or NotFound (if delivery wasn't persisted in memory store)
_capturedActivities.Should().NotBeEmpty("OTel spans should be captured for delivery lookup");
@@ -244,7 +250,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/deliveries?limit=10&status=pending");
var response = await client.GetAsync("/api/v1/notify/deliveries?limit=10&status=pending", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -270,7 +276,8 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
// Act
var response = await client.PostAsync(
"/api/v1/notify/templates",
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"),
CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -296,7 +303,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Add("traceparent", $"00-{parentTraceId}-{parentSpanId}-01");
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -314,7 +321,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -357,7 +364,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
// Act
var response = await client.GetAsync($"/api/v1/notify/rules/nonexistent-{Guid.NewGuid():N}");
var response = await client.GetAsync($"/api/v1/notify/rules/nonexistent-{Guid.NewGuid():N}", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -373,7 +380,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
// No auth header
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
@@ -394,7 +401,7 @@ public class NotifyWebServiceOTelTests : IClassFixture<WebApplicationFactory<Pro
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
// Act
var response = await client.GetAsync("/api/v1/notify/rules");
var response = await client.GetAsync("/api/v1/notify/rules", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);

View File

@@ -1,25 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<UseXunitV3>true</UseXunitV3>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit.v3" />
</ItemGroup> <ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />

View File

@@ -337,7 +337,7 @@ internal sealed class InMemoryEventQueue : INotifyEventQueue
{
var lease = new TrackingLease(message.Event);
_leases.Enqueue(lease);
return ValueTask.FromResult(new NotifyQueueEnqueueResult(true, lease.MessageId));
return ValueTask.FromResult(new NotifyQueueEnqueueResult(lease.MessageId, Deduplicated: false));
}
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)

View File

@@ -278,7 +278,7 @@ public class NotifyWorkerRetryTests
var message = new NotifyQueueEventMessage(@event, "notify:events", traceId: traceId);
var lease = new RetryTrackingLease(@event, traceId: traceId);
var queue = new RetryQueue(lease);
var handler = new TraceCapturingHandler();
var handler = new RetryTraceCapturingHandler();
var options = Options.Create(CreateWorkerOptions());
var processor = new NotifyEventLeaseProcessor(
queue, handler, options,
@@ -401,7 +401,7 @@ internal sealed class EventCapturingHandler : INotifyEventHandler
}
}
internal sealed class TraceCapturingHandler : INotifyEventHandler
internal sealed class RetryTraceCapturingHandler : INotifyEventHandler
{
public bool ShouldFail { get; set; }
public List<string?> CapturedTraceIds { get; } = new();