feat: validate single-client broadcast operations
Build web service image / build (push) Successful in 1m9s
Build web service image / build (push) Successful in 1m9s
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using WxAgent.Service;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Service.Tests;
|
||||
|
||||
public sealed class BroadcastOperationTests
|
||||
{
|
||||
private sealed class Backend(bool failSecond = false) : IAgentBackend
|
||||
{
|
||||
public List<string> SentTargets { get; } = [];
|
||||
|
||||
public IReadOnlyList<AgentCapability> Capabilities =>
|
||||
[
|
||||
new("sessions-list", true, true, true, true, false, "read", false, 30, null, []),
|
||||
new("broadcast-text", true, false, true, true, true, "write", true, 30, null, []),
|
||||
new("send-text", true, false, true, true, true, "write", false, 30, null, [])
|
||||
];
|
||||
|
||||
public Task<object> StatusAsync(CancellationToken cancellationToken) => Task.FromResult<object>(new { ok = true });
|
||||
|
||||
public Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<SessionInfo>>([
|
||||
new("A", "session-a", false),
|
||||
new("B", "session-b", false),
|
||||
new("C", "session-c", false)
|
||||
]);
|
||||
|
||||
public Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
if (failSecond && targetId == "session-b")
|
||||
throw new ServiceException("TargetUnavailable", 409, "Target is no longer available.");
|
||||
SentTargets.Add(targetId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BroadcastFreezesTargetsRunsInOrderAndReplaysIdempotently()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
|
||||
var token = new string('D', 43);
|
||||
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
|
||||
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], [])
|
||||
}));
|
||||
var backend = new Backend();
|
||||
await using var app = ServiceHost.Build(options, backend, b => b.WebHost.UseTestServer());
|
||||
try
|
||||
{
|
||||
await app.StartAsync();
|
||||
using var client = app.GetTestClient();
|
||||
client.BaseAddress = new Uri("http://localhost:5088");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var request = new
|
||||
{
|
||||
kind = "broadcast-text",
|
||||
accountId = "account-1",
|
||||
targets = new[] { "session-a", "session-a", "session-b" },
|
||||
text = "broadcast-test",
|
||||
idempotencyKey = "broadcast-1",
|
||||
confirmed = true,
|
||||
stopOnError = true
|
||||
};
|
||||
|
||||
var submitted = await client.PostAsJsonAsync("/api/v1/operations", request);
|
||||
Assert.Equal(HttpStatusCode.OK, submitted.StatusCode);
|
||||
var queued = await submitted.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var operationId = queued.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(operationId));
|
||||
var completed = await WaitForTerminalAsync(client, operationId!);
|
||||
Assert.Equal("Succeeded", completed.GetProperty("state").GetString());
|
||||
Assert.Equal(["session-a", "session-b"], backend.SentTargets);
|
||||
|
||||
var details = JsonDocument.Parse(completed.GetProperty("details").GetString()!).RootElement;
|
||||
Assert.Equal(["session-a", "session-b"], details.GetProperty("targetIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
|
||||
Assert.Equal(["session-a", "session-b"], details.GetProperty("items").EnumerateArray().Select(x => x.GetProperty("targetId").GetString()!).ToArray());
|
||||
Assert.All(details.GetProperty("items").EnumerateArray(), item => Assert.Equal("Succeeded", item.GetProperty("state").GetString()));
|
||||
|
||||
var replay = await client.PostAsJsonAsync("/api/v1/operations", request);
|
||||
var replayRecord = await replay.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(operationId, replayRecord.GetProperty("id").GetString());
|
||||
Assert.Equal(2, backend.SentTargets.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BroadcastStopsAfterKnownTargetFailureAndReportsPerItemResults()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
|
||||
var token = new string('E', 43);
|
||||
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
|
||||
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], [])
|
||||
}));
|
||||
var backend = new Backend(failSecond: true);
|
||||
await using var app = ServiceHost.Build(options, backend, b => b.WebHost.UseTestServer());
|
||||
try
|
||||
{
|
||||
await app.StartAsync();
|
||||
using var client = app.GetTestClient();
|
||||
client.BaseAddress = new Uri("http://localhost:5088");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var submitted = await client.PostAsJsonAsync("/api/v1/operations", new
|
||||
{
|
||||
kind = "broadcast-text", accountId = "account-1", targets = new[] { "session-a", "session-b", "session-c" },
|
||||
text = "broadcast-stop-test", idempotencyKey = "broadcast-stop-1", confirmed = true, stopOnError = true
|
||||
});
|
||||
var queued = await submitted.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var completed = await WaitForTerminalAsync(client, queued.GetProperty("id").GetString()!);
|
||||
Assert.Equal("Failed", completed.GetProperty("state").GetString());
|
||||
Assert.Equal(["session-a"], backend.SentTargets);
|
||||
var details = JsonDocument.Parse(completed.GetProperty("details").GetString()!).RootElement;
|
||||
Assert.True(details.GetProperty("stopped").GetBoolean());
|
||||
Assert.Equal(2, details.GetProperty("items").GetArrayLength());
|
||||
Assert.Equal("Failed", details.GetProperty("items")[1].GetProperty("state").GetString());
|
||||
Assert.Equal("TargetUnavailable", details.GetProperty("items")[1].GetProperty("errorCode").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> WaitForTerminalAsync(HttpClient client, string operationId)
|
||||
{
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var response = await client.GetAsync($"/api/v1/operations/{operationId}");
|
||||
var record = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var state = record.GetProperty("state").GetString();
|
||||
if (state is "Succeeded" or "Failed" or "Unconfirmed" or "Cancelled" or "Expired") return record;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
throw new TimeoutException("Broadcast operation did not reach a terminal state.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user