using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NewLife;
using NewLife.Log;
using NewLife.Remoting;
using NewLife.Remoting.Clients;
using NewLife.Remoting.Models;
using NewLife.Security;
using NewLife.Serialization;
using NewLife.Threading;
using Stardust;
using Stardust.Data;
using Stardust.Data.Configs;
using Stardust.Data.Monitors;
using Stardust.Monitors;
using Stardust.Server.Services;
using Xunit;
namespace ServerTest.Communication;
/// <summary>星尘SDK 应用客户端到æœåŠ¡ç«¯æŽ¥å£çš„端到端测试。使用真实 AppClient 连接真实 StarServer(真实 TCP 端å£ï¼‰</summary>
/// <remarks>
/// 应用客户端(AppClient)以 App/* å议与æœåŠ¡ç«¯é€šè®¯ï¼Œ
/// 本测试覆盖应用登录ã€å¿ƒè·³ã€æ³¨é”€ã€äº‹ä»¶ä¸ŠæŠ¥ã€æœåŠ¡æ³¨å†Œå‘现ã€å‘½ä»¤ä¸‹è¡Œå›žå¤ã€åŸ‹ç‚¹ä¸ŠæŠ¥ä¸Žé…ç½®ä¸å¿ƒæ‹‰å–ç‰å…¨éƒ¨æŽ¥å£é¢ã€‚
/// </remarks>
[Collection("StarServer")]
public class AppCommunicationTests
{
private readonly StarServerFixture _fixture;
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public AppCommunicationTests(StarServerFixture fixture)
{
_fixture = fixture;
// è¿½è¸ªæ•°æ®æŒ‰å¤©åˆ†è¡¨ï¼ŒSQLite 下需é…置分库连接
StarDataHelper.SplitSqliteTables();
}
#region 辅助
private AppClient CreateClient(String appId, String? secret = null, String? clientId = null)
{
var client = new AppClient(_fixture.ServerUrl)
{
AppId = appId,
Secret = secret,
Log = XTrace.Log,
};
if (!clientId.IsNullOrEmpty()) client.ClientId = clientId;
return client;
}
private static void CleanupApp(String name)
{
var app = App.FindByName(name);
if (app == null) return;
AppOnline.Delete(AppOnline._.AppId == app.Id);
app.Delete();
}
/// <summary>创建具备应用控制æƒé™çš„应用,并å–得应用令牌(模拟管ç†ç«¯/控制应用)</summary>
private async Task<(App app, String token)> CreateControllerAppAsync()
{
var name = "e2e_ctl_" + Rand.NextString(8);
var app = new App { Name = name, Secret = "e2e-secret-" + Rand.NextString(6), Enable = true, AllowControlNodes = "*" };
app.Insert();
using var http = new HttpClient { BaseAddress = new Uri(_fixture.ServerUrl) };
var rs = await http.PostAsync<LoginResponse>("app/login", new { AppId = name, Secret = app.Secret });
Assert.NotNull(rs?.Token);
return (app, rs!.Token!);
}
private async Task<CommandReplyModel?> SendAppCommandAsync(String token, String code, String command, String argument, Int32 timeout)
{
using var http = new HttpClient { BaseAddress = new Uri(_fixture.ServerUrl) };
http.DefaultRequestHeaders.Add("X-Token", token);
var resp = await http.PostAsync("/App/SendCommand", JsonContent.Create(new { code, command, argument, timeout }));
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode || body.IsNullOrEmpty()) return null;
return ParseApiResponse<CommandReplyModel>(body);
}
/// <summary>è§£æž ApiFilter 包装的å“应体({code,data,msg}),å–出 data è½½è·</summary>
private static T? ParseApiResponse<T>(String body)
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return default;
if (!doc.RootElement.TryGetProperty("data", out var data)) return default;
if (data.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return default;
return data.Deserialize<T>(_jsonOptions);
}
#endregion
#region 登录注销
[Fact(DisplayName = "应用通讯-应用登录:自动注册应用并é¢å‘令牌")]
public async Task Login_AutoRegister_IssuesToken()
{
var name = "e2e_app_" + Rand.NextString(8);
var client = CreateClient(name, clientId: "e2e_" + Rand.NextString(8));
try
{
var rs = await client.Login();
Assert.NotNull(rs);
Assert.True(client.Logined, "应用登录失败");
Assert.False(rs!.Token.IsNullOrEmpty(), "登录å“应缺少访问令牌");
var app = App.FindByName(name);
Assert.NotNull(app);
Assert.True(app!.Enable, "自动注册的应用应处于å¯ç”¨çжæ€");
}
finally
{
client.Dispose();
CleanupApp(name);
}
}
[Fact(DisplayName = "应用通讯-应用心跳:返回æœåŠ¡ç«¯æ—¶é—´å¹¶è®°å½•åœ¨çº¿")]
public async Task Ping_KeepsOnline()
{
var name = "e2e_app_" + Rand.NextString(8);
var client = CreateClient(name, clientId: "e2e_" + Rand.NextString(8));
try
{
Assert.NotNull(await client.Login());
var rs = await client.Ping();
Assert.NotNull(rs);
Assert.True(rs!.ServerTime > 0, "心跳å“åº”åº”åŒ…å«æœåŠ¡ç«¯æ—¶é—´");
var app = App.FindByName(name);
Assert.NotNull(app);
Assert.NotNull(AppOnline.Find(AppOnline._.AppId == app!.Id));
}
finally
{
client.Dispose();
CleanupApp(name);
}
}
[Fact(DisplayName = "应用通讯-应用注销:客户端退出登录")]
public async Task Logout_Works()
{
var name = "e2e_app_" + Rand.NextString(8);
var client = CreateClient(name, clientId: "e2e_" + Rand.NextString(8));
try
{
Assert.NotNull(await client.Login());
await client.Logout("e2e测试注销");
Assert.False(client.Logined);
}
finally
{
client.Dispose();
CleanupApp(name);
}
}
[Fact(DisplayName = "应用通讯-ç™»å½•æ ¡éªŒï¼šç¼ºå°‘åº”ç”¨æ ‡è¯†æ—¶è¿”å›žæ˜Žç¡®é”™è¯¯è€Œéž500")]
public async Task Login_MissingAppId_RejectedCleanly()
{
using var http = new HttpClient { BaseAddress = new Uri(_fixture.ServerUrl) };
// 䏿ºå¸¦ AppId 的登录请求(异常客户端或探测æµé‡ï¼‰
var resp = await http.PostAsync("app/login", JsonContent.Create(new { AppName = "e2e_no_id" }));
var body = await resp.Content.ReadAsStringAsync();
// æœåŠ¡ç«¯è¿”å›žæ˜Žç¡®çš„å‚æ•°é”™è¯¯ï¼ˆApiFilter 约定:HTTP 200 + é”™è¯¯æŠ¥æ–‡ï¼‰ï¼Œè€Œä¸æ˜¯å†…部空引用异常
Assert.Contains("åº”ç”¨æ ‡è¯†ä¸èƒ½ä¸ºç©º", body);
Assert.DoesNotContain("Object reference", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("NullReference", body, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region 事件上报
[Fact(DisplayName = "应用通讯-事件上报:批é‡å†™å…¥å¹¶è¿”å›žæ¡æ•°")]
public async Task PostEvents_Writes()
{
var name = "e2e_app_" + Rand.NextString(8);
var client = CreateClient(name, clientId: "e2e_" + Rand.NextString(8));
try
{
Assert.NotNull(await client.Login());
var n = await client.PostEvents(
[
new EventModel { Type = "e2e", Name = "comm_test", Remark = "ç«¯åˆ°ç«¯æŽ¥å£æµ‹è¯•", Time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() },
]);
Assert.True(n >= 1, $"事件上报应æˆåŠŸå†™å…¥ï¼Œå®žé™…è¿”å›ž {n}");
}
finally
{
client.Dispose();
CleanupApp(name);
}
}
#endregion
#region æœåŠ¡æ³¨å†Œå‘现
[Fact(DisplayName = "应用通讯-æœåŠ¡æ³¨å†Œï¼šå‘布ã€å‘çŽ°ä¸Žå–æ¶ˆæ³¨å†Œå…¨é“¾è·¯")]
public async Task Registry_Publish_Resolve_Unregister()
{
var publisherName = "e2e_pub_" + Rand.NextString(8);
var consumerName = "e2e_con_" + Rand.NextString(8);
var serviceName = "e2e_svc_" + Rand.NextString(6);
var address = "http://127.0.0.1:18080";
var publisher = CreateClient(publisherName, clientId: "e2e_" + Rand.NextString(8));
var consumer = CreateClient(consumerName, clientId: "e2e_" + Rand.NextString(8));
var service = Service.FindByName(serviceName);
try
{
Assert.NotNull(await publisher.Login());
Assert.NotNull(await consumer.Login());
// å‘布æœåŠ¡
await publisher.RegisterAsync(serviceName, address, "e2e");
// 消费å‘现æœåŠ¡
var services = await consumer.ResolveAsync(serviceName);
Assert.NotNull(services);
Assert.Contains(services!, x => x.ServiceName == serviceName && x.Address != null && x.Address.Contains("18080"));
// å–æ¶ˆæ³¨å†Œ
Assert.NotNull(publisher.Unregister(serviceName));
}
finally
{
publisher.Dispose();
consumer.Dispose();
// æ¸…ç†æ³¨å†Œä¸å¿ƒæ•°æ®
service ??= Service.FindByName(serviceName);
if (service != null)
{
AppService.Delete(AppService._.ServiceId == service.Id);
AppConsume.Delete(AppConsume._.ServiceId == service.Id);
service.Delete();
}
CleanupApp(publisherName);
CleanupApp(consumerName);
}
}
#endregion
#region æœåŠ¡æœç´¢
[Fact(DisplayName = "应用通讯-æœåŠ¡æœç´¢ï¼šæŒ‰å称检索已注册æœåŠ¡")]
public async Task SearchService_FindsRegisteredService()
{
var publisherName = "e2e_srch_" + Rand.NextString(8);
var serviceName = "e2e_ssvc_" + Rand.NextString(6);
var address = "http://127.0.0.1:18081";
var publisher = CreateClient(publisherName, clientId: "e2e_" + Rand.NextString(8));
var service = Service.FindByName(serviceName);
try
{
var login = await publisher.Login();
Assert.NotNull(login);
await publisher.RegisterAsync(serviceName, address, "e2e");
using var http = new HttpClient { BaseAddress = new Uri(_fixture.ServerUrl) };
http.DefaultRequestHeaders.Add("X-Token", login!.Token);
var resp = await http.PostAsync($"/App/SearchService?serviceName={serviceName}&key=e2e", null);
var body = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"æœåŠ¡æœç´¢å¤±è´¥ï¼šHTTP {(Int32)resp.StatusCode},å“应={body}");
// AppService çš„æ—¶é—´å—æ®µæ˜¯ XCode é»˜è®¤æ ¼å¼ï¼Œç›´æŽ¥ååºåˆ—åŒ–ä¼šè§¦å‘ DateTime è§£æžå¤±è´¥ï¼Œè¿™é‡Œåªå–地å€å—段
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.TryGetProperty("data", out var data), $"å“应缺少 data:{body}");
Assert.Equal(JsonValueKind.Array, data.ValueKind);
var addresses = data.EnumerateArray()
.Select(e => e.TryGetProperty("address", out var a) ? a.GetString() : null)
.Where(e => !e.IsNullOrEmpty())
.ToList();
Assert.Contains(addresses, e => e!.Contains("18081"));
}
finally
{
publisher.Dispose();
service ??= Service.FindByName(serviceName);
if (service != null)
{
AppService.Delete(AppService._.ServiceId == service.Id);
AppConsume.Delete(AppConsume._.ServiceId == service.Id);
service.Delete();
}
CleanupApp(publisherName);
}
}
#endregion
#region 命令下行
[Fact(DisplayName = "应用通讯-命令下行:WebSocket 推é€ã€æ‰§è¡Œä¸Žå›žå¤")]
public async Task Command_Downlink_And_Reply()
{
var (ctlApp, token) = await CreateControllerAppAsync();
var targetName = "e2e_tgt_" + Rand.NextString(8);
var clientId = "e2e_" + Rand.NextString(8);
var target = CreateClient(targetName, clientId: clientId);
try
{
// 注册命令处ç†å™¨
var received = new TaskCompletionSource<String>(TaskCreationOptions.RunContinuationsAsynchronously);
Func<String, String> handler = arg => { received.TrySetResult(arg ?? ""); return "pong:" + arg; };
target.RegisterCommand("e2e/echo", handler);
// 打开连接(登录并å¯åŠ¨å¿ƒè·³å®šæ—¶å™¨ï¼Œè§¦å‘ WebSocket 通知通é“)
target.Open();
for (var i = 0; i < 60 && !target.Logined; i++) await Task.Delay(250);
Assert.True(target.Logined, "ç›®æ ‡åº”ç”¨æœªèƒ½åœ¨è¶…æ—¶æ—¶é—´å†…ç™»å½•");
// ç‰å¾…æœåŠ¡ç«¯å»ºç«‹åº”ç”¨ä¼šè¯ï¼ˆWebSocket 长连接)
var code = $"{targetName}@{clientId}";
var sessions = _fixture.Host.Services.GetRequiredService<AppSessionManager>();
for (var i = 0; i < 60 && sessions.Get(code) == null; i++) await Task.Delay(250);
Assert.NotNull(sessions.Get(code));
// 控制应用下å‘命令
var reply = await SendAppCommandAsync(token, code, "e2e/echo", "hello", 15);
Assert.NotNull(reply);
Assert.Equal("pong:hello", reply!.Data);
// ç›®æ ‡åº”ç”¨ç¡®å®žæ‰§è¡Œäº†å¤„ç†å™¨
Assert.True(received.Task.Wait(TimeSpan.FromSeconds(3)), "ç›®æ ‡åº”ç”¨æœªæ‰§è¡Œå‘½ä»¤å¤„ç†å™¨");
}
finally
{
target.Dispose();
CleanupApp(targetName);
CleanupApp(ctlApp.Name);
}
}
#endregion
#region 埋点上报
[Fact(DisplayName = "应用通讯-埋点上报:追踪项与追踪数æ®è½åº“")]
public async Task TraceReport_CreatesTraceItemAndData()
{
var name = "e2e_trc_" + Rand.NextString(8);
var client = CreateClient(name, clientId: "e2e_" + Rand.NextString(8));
try
{
Assert.NotNull(await client.Login());
// 通过真实追踪器产生埋点数æ®
var spanName = "e2e_trace_" + Rand.NextString(6);
var tracer = new StarTracer { AppId = name, Client = client, TrimSelf = false, EnableMeter = false, Period = 3600 };
using (var span = tracer.NewSpan(spanName))
{
span?.SetTag("e2e=1");
}
// ç«‹å³å–出å‘é€ï¼Œé¿å…ç‰å¾…é‡‡æ ·å®šæ—¶å™¨
var builders = tracer.TakeAll();
Assert.NotEmpty(builders);
var model = new TraceModel { AppId = name, ClientId = client.ClientId, Builders = builders };
var resp = await client.InvokeAsync<TraceResponse>("Trace/Report", model);
Assert.NotNull(resp);
// 追踪项与追踪数æ®å·²æŒ‰åº”用è½åº“
var appTracer = AppTracer.FindByName(name);
Assert.NotNull(appTracer);
var item = TraceItem.FindByAppIdAndName(appTracer!.ID, spanName);
Assert.NotNull(item);
var datas = TraceData.Search(appTracer.ID, item!.Id, null, DateTime.Today, 10);
Assert.NotEmpty(datas);
}
finally
{
client.Dispose();
AppTracer.FindByName(name)?.Delete();
CleanupApp(name);
}
}
[Fact(DisplayName = "应用通讯-埋点上报(大报文):Trace/ReportRaw è½åº“")]
public async Task TraceReportRaw_BigPayload()
{
var name = "e2e_raw_" + Rand.NextString(8);
var client = CreateClient(name, clientId: "e2e_" + Rand.NextString(8));
try
{
Assert.NotNull(await client.Login());
// æž„é€ è¶…è¿‡ 1KB 的埋点报文,走客户端的原始å—节上报分支
var spanName = "e2e_raw_" + Rand.NextString(6);
var tracer = new StarTracer { AppId = name, Client = client, TrimSelf = false, EnableMeter = false, Period = 3600 };
using (var span = tracer.NewSpan(spanName))
{
span?.SetTag("big=" + new String('x', 1200));
}
var builders = tracer.TakeAll();
var model = new TraceModel { AppId = name, ClientId = client.ClientId, Builders = builders };
var json = model.ToJson();
Assert.True(json.Length > 1024);
var resp = await client.InvokeAsync<TraceResponse>("Trace/ReportRaw", json.GetBytes());
Assert.NotNull(resp);
var appTracer = AppTracer.FindByName(name);
Assert.NotNull(appTracer);
Assert.NotNull(TraceItem.FindByAppIdAndName(appTracer!.ID, spanName));
}
finally
{
client.Dispose();
AppTracer.FindByName(name)?.Delete();
CleanupApp(name);
}
}
#endregion
#region é…ç½®ä¸å¿ƒ
[Fact(DisplayName = "应用通讯-é…ç½®ä¸å¿ƒï¼šçœŸå®žå·¥åŽ‚æ‹‰å–å·²å‘布é…ç½®")]
public async Task ConfigCenter_PullPublishedConfig()
{
var name = "e2e_cfg_" + Rand.NextString(8);
var secret = "e2e-secret-" + Rand.NextString(6);
var app = new App { Name = name, Secret = secret, Enable = true };
app.Insert();
var key = "e2e_key_" + Rand.NextString(4);
var value = "e2e_value_" + Rand.NextString(4);
try
{
// æœåŠ¡ç«¯å†™å…¥å¹¶å‘布é…ç½®
using (var http = new HttpClient { BaseAddress = new Uri(_fixture.ServerUrl) })
{
var resp = await http.PostAsync($"/Config/SetAll?appId={name}&secret={secret}",
JsonContent.Create(new { AppId = name, Secret = secret, Configs = new Dictionary<String, Object> { [key] = value } }));
var body = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, "写入é…置失败:" + body);
}
AppConfig.FindByName(name)!.Publish();
// 客户端通过真实星尘工厂拉å–é…ç½®
using var star = new StarFactory(_fixture.ServerUrl, name, secret);
Assert.NotNull(star.App);
Assert.NotNull(await star.App!.Login());
var config = star.Config;
Assert.NotNull(config);
Assert.Equal(value, config![key]);
}
finally
{
// StarFactory 会把全局定时器时间æä¾›è€…指å‘自身的客户端,销æ¯åŽå¿…须还原为系统时间,é¿å…污染åŒè¿›ç¨‹å†…çš„æœåŠ¡ç«¯
TimerScheduler.GlobalTimeProvider = TimeProvider.System;
// 清ç†åº”用ã€é…置与在线记录
var cfg = AppConfig.FindByName(name);
if (cfg != null)
{
foreach (var item in ConfigData.FindAllByApp(cfg.Id)) item.Delete();
cfg.Delete();
}
CleanupApp(name);
}
}
#endregion
}
|