Files
Hua.Todo/test/Hua.Todo.Host.Tests/CloudSync/WebRootStaticFileTests.cs
T
ShaoHua 3213d4c718 feat: 完成v1.3.0架构对齐与Web端根路径托管全量开发
- 重构架构:抽离AddCloudSyncServer到扩展类,补全中间层文档对齐
- 修复根路径404与鉴权跳转:新增中间件处理浏览器请求302跳转
- 实现种子数据初始化:添加默认管理员账号自动创建逻辑
- 优化前端代理与构建:新增Vite代理配置与自动构建Target
- 补全测试与文档:新增集成测试,更新开发与部署文档
2026-08-08 07:33:44 +08:00

190 lines
7.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 模式 B(Web 端)根路径托管 Hua.Todo.Web 静态页的验收测试。
///
/// 依据 v1.3.0 工单 06 的 6 条 Given-When-Then:
/// 1. 根路径 / 返回 200 + HTML(含 &lt;div id="app"&gt;)
/// 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 顺序补齐中间件后,全部通过。
/// </summary>
public sealed class WebRootStaticFileTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly string _webRootDir;
private readonly HttpClient _client;
private readonly WebApplication _app;
/// <summary>
/// 写入到临时 wwwroot/index.html 的特征字符串,供断言用。
/// 避免与实际 Hua.Todo.Web 构建耦合(测试不需要真的 npm build)。
/// </summary>
private const string SpaMountPoint = "<div id=\"app\">SpaMount</div>";
private const string SpaTitle = "<title>Hua.Todo - ServedByHost</title>";
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"),
$"<!doctype html><html><head>{SpaTitle}</head><body>{SpaMountPoint}</body></html>");
// === 内存 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<TodoDbContext>(options => options.UseSqlite(_connection));
builder.Services.AddCloudSyncServer();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
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 含 <div id="app"> 挂载点
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 { /* 忽略临时文件清理失败 */ }
}
}