feat: add HTTP and MCP service

This commit is contained in:
2026-09-07 19:07:16 +08:00
parent 6077abd1ad
commit d660c3b9e6
37 changed files with 2186 additions and 0 deletions
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class ArtifactStoreTests
{
[Fact]
public async Task ArtifactIdsAreOpaqueOwnedBoundedAndPathSafe()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var options = new ServiceOptions { DataDirectory = dir, CredentialFile = "unused" };
try
{
var store = new ArtifactStore(options);
await using var body = new MemoryStream("safe"u8.ToArray());
var file = new FormFile(body, 0, body.Length, "file", "note.txt") { Headers = new HeaderDictionary(), ContentType = "text/plain" };
var info = await store.SaveAsync("alice", file, default);
Assert.Matches("^[0-9a-f]{32}$", info.ArtifactId);
Assert.Equal("alice", store.Get("alice", info.ArtifactId).PrincipalId);
Assert.Throws<ServiceException>(() => store.Get("bob", info.ArtifactId));
await using var opened = store.Open("alice", info.ArtifactId); using var reader = new StreamReader(opened);
Assert.Equal("safe", await reader.ReadToEndAsync());
Assert.Throws<ServiceException>(() => store.Get("alice", "../secrets"));
await using var badBody = new MemoryStream("x"u8.ToArray());
var bad = new FormFile(badBody, 0, badBody.Length, "file", "payload.exe") { Headers = new HeaderDictionary(), ContentType = "application/octet-stream" };
Assert.Equal("FileTypeRejected", (await Assert.ThrowsAsync<ServiceException>(() => store.SaveAsync("alice", bad, default))).Code);
}
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,35 @@
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class EventHubTests
{
[Fact]
public async Task ReplayAndGapAreExplicitAndSubscriptionsAreOwnerScoped()
{
var hub = new EventHub();
var first = new AgentEvent("one", "account", "chat", "message", "summary", DateTimeOffset.UtcNow);
hub.Publish("alice", first);
var replay = hub.Subscribe("alice", "one");
Assert.Empty(replay.Replay); Assert.False(replay.Gap);
var gap = hub.Subscribe("alice", "gone");
Assert.True(gap.Gap);
Assert.False(hub.TryGet(replay.SubscriptionId, "bob", out _));
hub.Publish("alice", new AgentEvent("two", "account", "chat", "message", "summary", DateTimeOffset.UtcNow));
Assert.True(hub.TryGet(replay.SubscriptionId, "alice", out var subscription));
Assert.True(await subscription.Channel.Reader.WaitToReadAsync());
}
[Fact]
public void SlowConsumerGetsAnExplicitGapInsteadOfSilentDrop()
{
var hub = new EventHub();
var subscription = hub.Subscribe("alice", null);
for (var i = 0; i < 102; i++) hub.Publish("alice", new AgentEvent(i.ToString(), "account", "chat", "message", "summary", DateTimeOffset.UtcNow));
Assert.True(hub.TryGet(subscription.SubscriptionId, "alice", out var current));
var foundGap = false;
while (current.Channel.Reader.TryRead(out var item)) foundGap |= item.Kind == "gap";
Assert.True(foundGap);
}
}
@@ -0,0 +1,41 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class EventPumpTests
{
private sealed class SourceBackend : IAgentBackend, IAgentEventSource
{
public IReadOnlyList<AgentCapability> Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public async IAsyncEnumerable<AgentEvent> ListenAsync([EnumeratorCancellation] CancellationToken ct)
{ yield return new AgentEvent("evt", "ui-current", "session", "message", "summary", DateTimeOffset.UtcNow); await Task.Delay(Timeout.Infinite, ct); }
}
[Fact]
public async Task PumpForwardsOnlyToAuthorizedPrincipals()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var options = new ServiceOptions { DataDirectory = dir, CredentialFile = Path.Combine(dir, "credentials.json") };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
{
new ServiceCredential("alice", new string('A', 64), ["read"], []),
new ServiceCredential("scoped", new string('B', 64), ["read"], ["other-account"])
}));
try
{
var hub = new EventHub(); var all = hub.Subscribe("alice", null); var scoped = hub.Subscribe("scoped", null);
using var pump = new EventPump(new SourceBackend(), hub, options);
await pump.StartAsync(default);
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!hub.TryGet(all.SubscriptionId, "alice", out var alice) || !alice.Channel.Reader.TryRead(out _))
await Task.Delay(10, ct.Token);
Assert.False(hub.TryGet(scoped.SubscriptionId, "scoped", out var no) && no.Channel.Reader.TryRead(out _));
await pump.StopAsync(default);
}
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,49 @@
using System.Text.Json;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class OperationQueueTests
{
[Fact]
public async Task QueueCancelsBeforeExecutionRevokesAtExecutionAndNeverOverlapsTimedOutAction()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var options = new ServiceOptions { DataDirectory = directory, CredentialFile = Path.Combine(directory, "auth.json") };
var hash = ServiceOptions.HashToken(new string('A', 43));
var identity = new ServiceIdentity("alice", hash, ["read", "write"], ["account"]);
void Credentials(string value) => File.WriteAllText(options.CredentialFile,
JsonSerializer.Serialize(new[] { new ServiceCredential("alice", value, identity.Permissions, identity.AccountIds) }));
Credentials(hash);
var capability = new AgentCapability("send", true, true, true, true, true, "write", false, 30, null, []);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
try
{
using var store = new OperationStore(options);
using var queue = new OperationQueue(store, options, new ServiceSecurity(options));
await queue.StartAsync(default);
var first = queue.Submit(identity, "account", capability, "first", "one", async _ => { entered.SetResult(); await release.Task; });
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10));
var calls = 0;
var second = queue.Submit(identity, "account", capability, "second", "two", _ => { Interlocked.Increment(ref calls); return Task.CompletedTask; });
Assert.Equal("Cancelled", queue.Cancel(identity, second.Id).State);
var third = queue.Submit(identity, "account", capability, "third", "three", _ => { Interlocked.Increment(ref calls); return Task.CompletedTask; });
queue.Cancel(identity, first.Id);
// Cancel is cooperative: the first action still owns the lane until it really exits.
Assert.Equal("Running", store.Get(first.Id, "alice").State);
Assert.Equal("Queued", store.Get(third.Id, "alice").State);
Credentials(ServiceOptions.HashToken(new string('B', 43)));
release.SetResult();
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
while (store.Get(third.Id, "alice").State == "Queued") await Task.Delay(10, deadline.Token);
Assert.Equal(0, calls);
Assert.Equal("Unconfirmed", store.Get(first.Id, "alice").State);
Assert.Equal("AuthorizationRevoked", store.Get(third.Id, "alice").ErrorCode);
await queue.StopAsync(default);
}
finally { release.TrySetResult(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,46 @@
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class OperationStoreTests
{
[Fact]
public void DurableIdempotencyOwnershipCapacityAndRestartNeverReplay()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var options = new ServiceOptions { DataDirectory = directory, CredentialFile = "unused" };
try
{
string runningId;
string queuedId;
using (var store = new OperationStore(options))
{
var first = store.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
runningId = first.Record.Id;
Assert.True(first.Created);
var again = store.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
Assert.False(again.Created);
Assert.Equal(runningId, again.Record.Id);
Assert.Equal("IdempotencyConflict", Assert.Throws<ServiceException>(() =>
store.Enqueue("alice", "account", "send-text", "one", "different", true, TimeSpan.FromMinutes(1), 2)).Code);
Assert.Equal("NotFound", Assert.Throws<ServiceException>(() => store.Get(runningId, "bob")).Code);
queuedId = store.Enqueue("bob", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2).Record.Id;
Assert.Equal("QueueFull", Assert.Throws<ServiceException>(() =>
store.Enqueue("alice", "account", "send-text", "two", "digest", true, TimeSpan.FromMinutes(1), 2)).Code);
store.Transition(runningId, "Running", "executing");
}
using (var restarted = new OperationStore(options))
{
Assert.Equal("Unconfirmed", restarted.Get(runningId, "alice").State);
Assert.Equal("Cancelled", restarted.Get(queuedId, "bob").State);
var again = restarted.Enqueue("alice", "account", "send-text", "one", "digest", true, TimeSpan.FromMinutes(1), 2);
Assert.False(again.Created);
Assert.Equal("Unconfirmed", again.Record.State);
restarted.Transition(runningId, "Succeeded", "late-completion");
Assert.Equal("Unconfirmed", restarted.Get(runningId, "alice").State);
}
}
finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,59 @@
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 ServiceBoundaryTests
{
private sealed class Backend : IAgentBackend
{
public IReadOnlyList<AgentCapability> Capabilities =>
[
new("agent-status", true, true, true, false, false, "read", false, 30, null, []),
new("accounts-list", true, true, true, false, false, "read", false, 30, null, []),
new("sessions-list", true, true, true, true, false, "read", false, 30, null, []),
new("messages-read", true, true, true, true, false, "read", false, 30, null, []),
new("contacts-list", true, true, true, false, false, "read", false, 30, null, [])
];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<AccountInfo>>([new("account-1", null, null, null, "fingerprint", false)]);
}
[Fact]
public async Task StaticUiAndBoundedReadOnlyPagesAreRealProductionRoutes()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('A', 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"], []) }));
await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer());
try
{
await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088");
var page = await client.GetAsync("/"); Assert.Equal(HttpStatusCode.OK, page.StatusCode); Assert.Contains("WxAgent", await page.Content.ReadAsStringAsync());
Assert.True(page.Headers.Contains("X-Content-Type-Options")); Assert.True(page.Headers.Contains("Content-Security-Policy"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var accounts = await client.GetFromJsonAsync<JsonElement>("/api/v1/accounts?limit=1&offset=0");
Assert.Equal(1, accounts.GetProperty("items").GetArrayLength()); Assert.False(accounts.GetProperty("hasMore").GetBoolean());
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/accounts?limit=201")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/accounts?offset=-1")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/status?token=leak")).StatusCode);
}
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
[Fact]
public void ExternalBindingRequiresExplicitOptIn()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var options = new ServiceOptions { ListenUrl = "http://192.0.2.10:5088", CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, "[]");
try { Assert.Throws<ArgumentException>(() => options.Validate()); }
finally { Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.TestHost;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class ServiceTests
{
private sealed class Backend : IAgentBackend
{
public IReadOnlyList<AgentCapability> Capabilities =>
[
new("agent-status", true, true, true, false, false, "read", false, 30, null, []),
new("accounts-list", true, true, true, false, false, "read", false, 30, null, []),
new("sessions-list", true, true, true, false, false, "read", false, 30, null, []),
new("messages-read", true, true, true, true, false, "read", false, 30, null, []),
new("contacts-list", true, true, true, false, false, "read", false, 30, null, []),
new("listener-events", true, true, true, true, false, "read", false, 30, null, [])
];
public Task<object> StatusAsync(CancellationToken cancellationToken) => Task.FromResult<object>(new { online = true, wechatAvailable = false });
}
[Fact]
public async Task RealRoutesAndMcpShareCredentialsAndRejectRevokedBrowserSession()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var token = new string('A', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(directory, "credentials.json"), DataDirectory = directory };
void Rotate(string value) => File.WriteAllText(options.CredentialFile,
JsonSerializer.Serialize(new[] { new ServiceCredential("alice", ServiceOptions.HashToken(value), ["read"], []) }));
Rotate(token);
await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer());
try
{
await app.StartAsync();
using var client = app.GetTestClient();
client.BaseAddress = new Uri("http://localhost:5088");
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Add("Origin", "http://evil.invalid");
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Remove("Origin");
client.DefaultRequestHeaders.Host = "evil.invalid";
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Host = null;
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream");
using var initialize = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 1, method = "initialize", @params = new { protocolVersion = "2025-11-25", capabilities = new { }, clientInfo = new { name = "wxagent-integration", version = "1" } } });
Assert.Equal(HttpStatusCode.OK, initialize.StatusCode);
using var list = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 2, method = "tools/list" });
var schema = await list.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, list.StatusCode);
Assert.Contains("agent_status", schema);
Assert.Contains("inputSchema", schema);
using var call = await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 3, method = "tools/call", @params = new { name = "agent_status", arguments = new { } } });
Assert.Equal(HttpStatusCode.OK, call.StatusCode);
Assert.Contains("wechatAvailable", await call.Content.ReadAsStringAsync());
client.DefaultRequestHeaders.Authorization = null;
client.DefaultRequestHeaders.Add("Origin", "http://localhost:5088");
using var login = await client.PostAsJsonAsync("/api/v1/login", new { token });
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
var cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
var result = await login.Content.ReadFromJsonAsync<JsonElement>();
client.DefaultRequestHeaders.Add("Cookie", cookie);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync("/api/v1/logout", null)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
Rotate(new string('B', 43));
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/mcp", new { jsonrpc = "2.0", id = 4, method = "tools/list" })).StatusCode);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", new string('B', 43));
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
}
finally { await app.StopAsync(); Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,43 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Net.Http.Json;
using Microsoft.AspNetCore.TestHost;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class SessionTests
{
private sealed class Backend : IAgentBackend
{
public IReadOnlyList<AgentCapability> Capabilities =>
[
new("sessions-search", true, true, true, true, false, "read", false, 30, null, []),
new("session-current", true, true, true, true, false, "read", false, 30, null, []),
new("sessions-scroll", true, false, false, true, false, "manage", false, 30, "not validated", [])
];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
public Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SessionSearchInfo>>([new("测试", "stable-1", true)]);
public Task<SessionInfo?> CurrentSessionAsync(CancellationToken ct) => Task.FromResult<SessionInfo?>(new("测试", "stable-1", true));
}
[Fact]
public async Task SearchCurrentAndDisabledNavigationHaveExplicitContracts()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('A', 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"], []) }));
await using var app = ServiceHost.Build(options, new 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 current = await client.GetFromJsonAsync<SessionInfo>("/api/v1/sessions/current"); Assert.Equal("stable-1", current!.AutomationId);
var search = await client.GetFromJsonAsync<JsonElement>("/api/v1/sessions/search?query=x&exactOnly=true"); Assert.Equal("stable-1", search.GetProperty("items")[0].GetProperty("automationId").GetString());
var scroll = await client.PostAsJsonAsync("/api/v1/sessions/scroll", new { direction = "down", pages = 1 }); Assert.Equal(HttpStatusCode.Forbidden, scroll.StatusCode);
}
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
+45
View File
@@ -0,0 +1,45 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using WxAgent.Service;
using Xunit;
namespace WxAgent.Service.Tests;
public sealed class SseTests
{
private sealed class Backend : IAgentBackend
{
public IReadOnlyList<AgentCapability> Capabilities => [new("listener-events", true, true, true, true, false, "read", false, 30, null, [])];
public Task<object> StatusAsync(CancellationToken ct) => Task.FromResult<object>(new { ok = true });
}
[Fact]
public async Task EventStreamUsesRealSseFramesAndRedactsMessageContent()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('A', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("alice", ServiceOptions.HashToken(token), ["read"], []) }));
await using var app = ServiceHost.Build(options, new 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);
using var response = await client.GetAsync("/api/v1/events", HttpCompletionOption.ResponseHeadersRead);
Assert.Equal(200, (int)response.StatusCode);
var hub = app.Services.GetRequiredService<EventHub>();
hub.Publish("alice", new AgentEvent("evt-1", "ui-current", "test", "message", "Text", DateTimeOffset.UtcNow));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var reader = new StreamReader(await response.Content.ReadAsStreamAsync(timeout.Token));
Assert.Equal("id: evt-1", await reader.ReadLineAsync(timeout.Token));
Assert.Equal("event: message", await reader.ReadLineAsync(timeout.Token));
var data = await reader.ReadLineAsync(timeout.Token);
Assert.StartsWith("data: ", data);
Assert.DoesNotContain("content", data, StringComparison.OrdinalIgnoreCase);
Assert.Equal(string.Empty, await reader.ReadLineAsync(timeout.Token));
}
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.28" />
<ProjectReference Include="../../src/WxAgent.Service/WxAgent.Service.csproj" />
</ItemGroup>
</Project>