Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
{
|
||||
@@ -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();
|
||||
@@ -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()
|
||||
{
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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(@"
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user