using System.Diagnostics;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using XCode;
using XCode.DataAccessLayer;
using XCode.Membership;
using Xunit;
namespace Stardust.WebApiTests.Fixtures;
/// <summary>
/// é›†æˆæµ‹è¯•的主机工厂:å¯åŠ¨çœŸå®žçš„ Stardust.WebApi 主机,并把数æ®åº“é‡å®šå‘到独立临时目录。
/// </summary>
/// <remarks>
/// 两个必须隔离的对象:
/// 1. SQLite 库文件——é¿å…与æ£åœ¨è¿è¡Œçš„ 6681 实例争抢åŒä¸€ä¸ª Data/Stardust.db 导致é”冲çªï¼›
/// 2. 星尘é…ç½®ä¸å¿ƒâ€”—本机 Star.config / StarAgent ä¼šä¸‹å‘ MSSQL 连接串,连ä¸ä¸Šä¼šæ‹–垮测试。
///
/// è¿žæŽ¥ä¸²å¿…é¡»åœ¨æœ¬è¿›ç¨‹ä»»ä½•å®žä½“é¦–æ¬¡è®¿é—®ä¹‹å‰æ”¹å†™ï¼šXCode çš„ DAL 从 appsettings.json 读å–连接串并缓å˜å®žä¾‹ï¼Œ
/// 而 AddCube() 在应用 ConfigureServices 阶段就会访问 Menu/Role。若晚于这个时点改写,
/// æˆå‘˜ä½“系的èœå•与角色ä»è½åœ¨é»˜è®¤ Data 目录,而用户è½åœ¨ä¸´æ—¶åº“,表现为登录æˆåŠŸå´å¤„处 403。
/// </remarks>
public class WebApiTestFactory : WebApplicationFactory<Program>
{
public const String AuthScheme = "Bearer";
public const String DefaultUserName = "admin";
public const String DefaultPassword = "admin";
private readonly String _dbDir;
private readonly Dictionary<String, String?> _savedEnv = [];
private readonly Lazy<Task<String>> _token;
public WebApiTestFactory()
{
// å…³é— XCode çš„ SQL 与调试日志:测试会大é‡å»ºè¡¨ä¸ŽæŸ¥è¯¢ï¼ŒSQL æ—¥å¿—æ˜¯æ—¥å¿—ä½“ç§¯å’Œè€—æ—¶çš„ä¸»è¦æ¥æº
DisableDebugLog();
XCodeSetting.Current.ShowSQL = false;
XCodeSetting.Current.Debug = false;
_dbDir = Path.Combine(Path.GetTempPath(), "stardust_webapi_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dbDir);
// 先于主机å¯åŠ¨æ”¹å†™è¿žæŽ¥ä¸²ï¼Œä¿è¯ AddCube() åˆå§‹åŒ–æˆå‘˜ä½“系时也走临时库
OverrideConnections();
// 星尘客户端按环境å˜é‡è¯»å–æœåŠ¡ç«¯åœ°å€ï¼Œé¿å…连接到本机 StarAgent 下å‘的外部地å€
SetEnvironment("StarServer", "http://127.0.0.1:1");
_token = new Lazy<Task<String>>(LoginAsync);
}
/// <summary>把 XCode 的四个连接串改写到临时目录。DAL 以连接å为键缓å˜å®žä¾‹ï¼Œæ”¹å†™åŽéœ€é‡æ–°åˆ›å»º</summary>
private void OverrideConnections()
{
foreach (var item in BuildConnectionStrings())
DAL.AddConnStr(item.Key, item.Value, null, "SQLite");
}
/// <summary>把 XCode é…置文件改æˆå…³é—调试与 SQL 日志。适用工程å¯åŠ¨æ—¶ä¼šé‡æ–°è¯»å–该文件,仅é 内å˜èµ‹å€¼ä¸å¯é </summary>
private static void DisableDebugLog()
{
try
{
var dir = Path.Combine(AppContext.BaseDirectory, "Config");
Directory.CreateDirectory(dir);
var file = Path.Combine(dir, "XCode.config");
if (File.Exists(file))
{
var xml = File.ReadAllText(file);
var changed = false;
if (xml.Contains("<ShowSQL>true</ShowSQL>"))
{
xml = xml.Replace("<ShowSQL>true</ShowSQL>", "<ShowSQL>false</ShowSQL>");
changed = true;
}
if (xml.Contains("<Debug>true</Debug>"))
{
xml = xml.Replace("<Debug>true</Debug>", "<Debug>false</Debug>");
changed = true;
}
if (changed) File.WriteAllText(file, xml);
}
else
{
File.WriteAllText(file, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n<XCode>\r\n <Debug>false</Debug>\r\n <ShowSQL>false</ShowSQL>\r\n</XCode>\r\n");
}
}
catch
{
// é…置文件ä¸å¯å†™æ—¶é€€å›žåˆ°å†…å˜èµ‹å€¼
}
}
/// <summary>è¿žæŽ¥ä¸²ã€‚æž„é€ å‡½æ•°ä¹‹å¤–ï¼Œä¸»æœºé…置阶段还è¦å†ç”¨ä¸€æ¬¡ã€‚</summary>
private Dictionary<String, String> BuildConnectionStrings() => new()
{
["Stardust"] = $"Data Source={_dbDir}/Stardust.db;provider=sqlite",
["StardustData"] = $"Data Source={_dbDir}/StardustData.db;provider=sqlite",
["Membership"] = $"Data Source={_dbDir}/Membership.db;provider=sqlite",
["Cube"] = $"Data Source={_dbDir}/Cube.db;provider=sqlite",
};
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// 主机é…置阶段å†è¦†ç›–一次:应用 ConfigureServices(AddStardust/AddCube ç‰ï¼‰å¯èƒ½ä¾æ® appsettings.json
// 釿–°è§£æžè¿žæŽ¥ä¸²ï¼Œè¿™é‡Œç¡®ä¿ FixTable 与åŽç»å®žä½“æ“作都走临时 SQLite 库。
builder.ConfigureServices((_, _) => OverrideConnections());
// åŒæ¥ IConfiguration 侧的连接串,供从é…置读å–连接串的组件使用。
// 用 UseSetting è€Œéž AddInMemoryCollectionï¼ŒçœæŽ‰å¯¹ Microsoft.Extensions.Configuration 包的ä¾èµ–。
foreach (var item in BuildConnectionStrings())
builder.UseSetting($"ConnectionStrings:{item.Key}", item.Value);
}
/// <summary>带 Bearer 认è¯å¤´çš„ HttpClient。首次调用触å‘ä¸€æ¬¡ç™»å½•å¹¶ç¼“å˜ token。</summary>
public async Task<HttpClient> CreateAuthenticatedClient()
{
var token = await _token.Value;
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AuthScheme, token);
return client;
}
/// <summary>真实登录拿 token。用裸 client 调用,é¿å…递归进 CreateAuthenticatedClient。</summary>
private async Task<String> LoginAsync()
{
using var bareClient = CreateClient();
// èœå•扫æä¸Žè§’色授æƒéƒ½æ˜¯ä¸»æœºå¯åЍåŽçš„åŽå°ä»»åŠ¡ï¼Œå¿…é¡»å…ˆç‰å®ƒä»¬è½åº“,å¦åˆ™é¦–ä¸ªä¸šåŠ¡è¯·æ±‚ä¼šå› æ— æƒé™è¢«æ‹’
Assert.True(WaitForCubeBootstrap(TimeSpan.FromSeconds(30)), "Cube æˆå‘˜ä½“系未在 30 秒内完æˆå¼•导(èœå•或系统角色授æƒç¼ºå¤±ï¼‰");
var resp = await bareClient.PostAsJsonAsync("/Auth/Login", new
{
Username = DefaultUserName,
Password = DefaultPassword,
});
var body = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"登录失败:HTTP {(Int32)resp.StatusCode},å“应={body}");
var token = ParseToken(body);
Assert.False(String.IsNullOrEmpty(token), $"登录å“åº”æœªå« token,å“应={body}");
return token!;
}
/// <summary>
/// ç‰å¾… Cube æˆå‘˜ä½“系引导完æˆï¼šç›®æ ‡èœå•å·²è½åº“,且系统角色已获得该èœå•的查看æƒé™ã€‚
/// </summary>
/// <remarks>
/// èœå•ç”± <c>AreaBase.RegisterArea</c> çš„åŽå°ä»»åŠ¡æ‰«æç”Ÿæˆï¼Œè§’色授æƒéšåŽç”± <c>Role.CheckRole</c> è¡¥é½ï¼Œ
/// 两者都是异æ¥çš„。这里按"èœå•å˜åœ¨ + 系统角色拥有 Detail æƒé™"åˆ¤å®šå°±ç»ªï¼Œè€Œä¸æ˜¯å›ºå®š sleep。
/// 查询使用ä¸å¸¦ç¼“å˜çš„ FindAll é‡è½½ï¼Œé¿å…å®žä½“ç¼“å˜æŠŠç©ºç»“æžœç•™ä½ã€‚
/// </remarks>
/// <param name="timeout">最长ç‰å¾…æ—¶é—´</param>
/// <returns>就绪返回 true,超时返回 false</returns>
private static Boolean WaitForCubeBootstrap(TimeSpan timeout)
{
var sw = Stopwatch.StartNew();
while (sw.Elapsed < timeout)
{
try
{
var menu = Menu.FindAll("Name='AppConfig'", null, null, 0, 1).FirstOrDefault();
if (menu != null)
{
var role = Role.FindAll("IsSystem=1", null, null, 0, 1).FirstOrDefault();
if (role != null && role.Has(menu.ID, PermissionFlags.Detail)) return true;
}
}
catch (Exception)
{
// æ•°æ®åº“尚未建表或æ£è¢«å†™å…¥ï¼Œç»§ç»ç‰å¾…
}
Thread.Sleep(200);
}
return false;
}
/// <summary>兼容 access_token / AccessToken 两ç§å—段åè§£æžä»¤ç‰Œã€‚</summary>
private static String? ParseToken(String json)
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
{
if (data.TryGetProperty("access_token", out var at1) && !String.IsNullOrEmpty(at1.GetString())) return at1.GetString();
if (data.TryGetProperty("AccessToken", out var at2) && !String.IsNullOrEmpty(at2.GetString())) return at2.GetString();
}
return null;
}
private void SetEnvironment(String name, String value)
{
_savedEnv[name] = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
}
protected override void Dispose(Boolean disposing)
{
if (disposing)
{
// 必须先åœä¸»æœºï¼ˆbase.Dispose 释放 SQLite è¿žæŽ¥æ± ä¸Žæ–‡ä»¶é”),å†åˆ 目录;
// å¦åˆ™ç›®å½•内文件ä»è¢«é”,Directory.Delete 会抛 IOException。
base.Dispose(disposing);
foreach (var item in _savedEnv) Environment.SetEnvironmentVariable(item.Key, item.Value);
try
{
Directory.Delete(_dbDir, true);
}
catch (IOException)
{
// æžç«¯æƒ…况䏋锿œªå³æ—¶é‡Šæ”¾ï¼Œç•™å¾…系统清ç†ä¸´æ—¶ç›®å½•,ä¸å½±å“测试结果
}
}
}
}
|