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 SentTargets { get; } = []; public IReadOnlyList 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 StatusAsync(CancellationToken cancellationToken) => Task.FromResult(new { ok = true }); public Task> SessionsAsync(CancellationToken cancellationToken) => Task.FromResult>([ 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(); 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(); 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(); 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 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(); 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."); } }