152 lines
7.4 KiB
C#
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.");
|
|
}
|
|
}
|