3213d4c718
- 重构架构:抽离AddCloudSyncServer到扩展类,补全中间层文档对齐 - 修复根路径404与鉴权跳转:新增中间件处理浏览器请求302跳转 - 实现种子数据初始化:添加默认管理员账号自动创建逻辑 - 优化前端代理与构建:新增Vite代理配置与自动构建Target - 补全测试与文档:新增集成测试,更新开发与部署文档
190 lines
7.7 KiB
C#
190 lines
7.7 KiB
C#
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(含 <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 顺序补齐中间件后,全部通过。
|
||
/// </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 { /* 忽略临时文件清理失败 */ }
|
||
}
|
||
}
|