using System.Net; using System.Net.Http.Json; using Hua.Todo.Application.Common; using Hua.Todo.Application.Repositories; using Hua.Todo.Application.Services.CloudSync.Models; using Hua.Todo.HttpApi.AspNetCore.CloudSync; using Hua.Todo.HttpApi.AspNetCore.DynamicApi; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Xunit; namespace Hua.Todo.Host.Tests.CloudSync; /// /// 模式 B(Web 端)根路径托管 Hua.Todo.Web 静态页的验收测试。 /// /// 依据 v1.3.0 工单 06 的 6 条 Given-When-Then: /// 1. 根路径 / 返回 200 + HTML(含 <div id="app">) /// 2. SPA 内部路由 /settings fallback 到 index.html /// 3. /api/* 动态 API 不被 fallback 覆盖 /// 4. /swagger 不被 fallback 覆盖(中间件顺序保证) /// /// RED 阶段:当前 Program.cs 没有 UseDefaultFiles / UseStaticFiles / MapFallbackToFile, /// 因此根路径断言会失败(404,body 非 HTML)。 /// GREEN 阶段:按工单 06 §五 5.1 的 Program.cs 顺序补齐中间件后,全部通过。 /// public sealed class WebRootStaticFileTests : IDisposable { private readonly SqliteConnection _connection; private readonly string _webRootDir; private readonly HttpClient _client; private readonly WebApplication _app; /// /// 写入到临时 wwwroot/index.html 的特征字符串,供断言用。 /// 避免与实际 Hua.Todo.Web 构建耦合(测试不需要真的 npm build)。 /// private const string SpaMountPoint = "
SpaMount
"; private const string SpaTitle = "Hua.Todo - ServedByHost"; public WebRootStaticFileTests() { // === 将临时 wwwroot/index.html 写入默认 WebRoot(无需改 WebRoot,避免 WebApplicationBuilder.WebHost.UseWebRoot 抛 NotSupportedException)=== // WebApplicationBuilder 启动后默认 WebRoot = AppContext.BaseDirectory/wwwroot(即 bin/Debug/net10.0/wwwroot) var baseDir = AppContext.BaseDirectory; _webRootDir = Path.Combine(baseDir, "wwwroot"); Directory.CreateDirectory(_webRootDir); File.WriteAllText( Path.Combine(_webRootDir, "index.html"), $"{SpaTitle}{SpaMountPoint}"); // === 内存 SQLite(与既有集成测试同模式) === _connection = new SqliteConnection("Data Source=WebRootStaticFileTests;Mode=Memory;Cache=Shared"); _connection.Open(); _app = BuildHost(environmentName: "Development"); _client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { BaseAddress = new Uri(_app.Urls.First()), }; } private WebApplication BuildHost(string environmentName) { var builder = WebApplication.CreateBuilder(); builder.Environment.EnvironmentName = environmentName; builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddApplicationServices("Data Source=WebRootStaticFileTests;Mode=Memory;Cache=Shared"); builder.Services.AddDbContext(options => options.UseSqlite(_connection)); builder.Services.AddCloudSyncServer(); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); } if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // === GREEN:按 Program.cs 硬约束顺序启用静态文件中间件 + SPA fallback === app.UseDefaultFiles(); app.UseStaticFiles(); app.UseAuthentication(); app.UseAuthorization(); app.UseDynamicApi(); // SPA fallback(在 UseDynamicApi 之后,不覆盖 /api/*) app.MapFallbackToFile("index.html"); app.StartAsync().GetAwaiter().GetResult(); return app; } [Fact] public async Task Root_Get_ReturnsIndexHtml_WithAppMountDiv() { // Given: Host 启动且 WebRoot/wwwroot 有 index.html // When : HTTP GET / // Then : 200 + text/html + body 含
挂载点 using var response = await _client.GetAsync("/"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase); var body = await response.Content.ReadAsStringAsync(); Assert.Contains(SpaMountPoint, body, StringComparison.Ordinal); Assert.Contains(SpaTitle, body, StringComparison.Ordinal); } [Fact] public async Task SpaRoute_Settings_Get_FallsBackToIndexHtml_Not404() { // Given: 同上 // When : HTTP GET /settings(前端内部路由) // Then : 200 + body 是 index.html(注意:404 是 RED 时的现状,也是本测试在 RED 阶段失败的原因) using var response = await _client.GetAsync("/settings"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); Assert.Contains(SpaMountPoint, body, StringComparison.Ordinal); } [Fact] public async Task ApiDynamicApiRoute_PostProbe_NotOverriddenByFallback() { // Given: 同上 // When : HTTP POST /api/cloud-sync/probe(匿名 DynamicApi 端点) // Then : 返回 JSON(body 含 isReachable 字段),不是 index.html // 不要求 isReachable=true,因为 test 里 Host 没监听 5173,必 false;也可能 503,但不是 HTML。 using var response = await _client.PostAsJsonAsync("/api/cloud-sync/probe", new ProbeRequest { TargetUrl = "http://localhost:5173" }); // 状态码不是 404 / 不返回 fallback 的 HTML: Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); Assert.DoesNotContain(SpaMountPoint, body, StringComparison.Ordinal); // 至少含 DynamicApi 的 JSON 特征:isReachable 字段 Assert.Contains("isReachable", body, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task SwaggerUi_Get_Returns200_NotOverriddenByFallback() { // Given: 开发环境,Swagger 启用 // When : HTTP GET /swagger/index.html // Then : 200,body 含 Swagger UI 的 "Swagger UI" 或 "swagger-ui",不是前端 index.html using var response = await _client.GetAsync("/swagger/index.html"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); Assert.Contains("swagger", body, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(SpaMountPoint, body, StringComparison.Ordinal); } public void Dispose() { _client.Dispose(); try { _app.StopAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ } try { _app.DisposeAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ } _connection.Dispose(); // 清理临时写入的 index.html(只删我们自己写的,不删目录里的其他可能文件) try { var idx = Path.Combine(_webRootDir, "index.html"); if (File.Exists(idx)) File.Delete(idx); } catch { /* 忽略临时文件清理失败 */ } } }