Files
wx-win-agent/tests/node-agent/WxAgent.Service.Tests/BroadcastOperationTests.cs
T
rogee c7c0ab273f
Build web service image / build (push) Successful in 1m9s
feat: validate single-client broadcast operations
2026-09-19 14:33:26 +08:00

152 lines
7.4 KiB
C#

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.");
}
}