using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Stardust.WebApiTests.Fixtures;
using Xunit;
using Xunit.Abstractions;
namespace Stardust.WebApiTests.Spa;
/// <summary>
/// SPA 托管与回退测试:验è¯å†…嵌的新控制å°å‰ç«¯åœ¨æ ¹è·¯å¾„与 History 深链å¯ç”¨ï¼Œ
/// 且 /api 机器端点的 404 ä¸è¢« SPA å›žé€€åžæŽ‰ï¼ˆé”æ–¹çš®è‚¤ç³»ç»Ÿ MapFallback("/api/{**path}", 404) ä¿æŠ¤ï¼‰ã€‚
/// </summary>
/// <remarks>
/// å‰ç«¯äº§ç‰©åœ¨ç¼–译期嵌入 Stardust.WebApi 程åºé›†ï¼ˆcsproj 嵌 wwwroot\**\*,由 pnpm build:all åŒæ¥ dist 生æˆï¼‰ï¼Œ
/// è¿è¡Œæ—¶ç”±é”方皮肤系统托管(ApiSpaSkin : ICubeSkin 自动å‘现)。
/// 产物缺失(未执行 pnpm build:all 的纯åŽç«¯æž„建/CI)时用例直接返回,ä¸é˜»å¡žé—¨ç¦ã€‚
/// 回退端点默认 {*path:nonfile} 约æŸï¼ˆå®žæµ‹ï¼‰ï¼šæ— æ‰©å±•åæœªåŒ¹é…路径回退 index.html(no-cache);
/// 带扩展å的缺失资æºä¿æŒ 404ã€POST UI 路径返回 405——机器è¯ä¹‰ä¸å—å½±å“,æ¤å¤„一并é”定。
/// </remarks>
[Collection(WebApiCollection.Name)]
public class SpaFallbackTests
{
private static Regex _spaEmbedded = new("^Stardust\\.Web\\.wwwroot\\.index\\.html$", RegexOptions.IgnoreCase);
private readonly WebApiTestFactory _factory;
private readonly ITestOutputHelper _output;
public SpaFallbackTests(WebApiTestFactory factory, ITestOutputHelper output)
{
_factory = factory;
_output = output;
}
/// <summary>当剿˜¯å¦å†…嵌了å‰ç«¯å…¥å£ã€‚缺失时跳过回退类æ–è¨€ï¼Œä¿æŒçº¯åŽç«¯æž„建å¯è·‘。</summary>
private Boolean SpaEmbedded => typeof(Program).Assembly.GetManifestResourceNames().Any(e => _spaEmbedded.IsMatch(e));
/// <summary>带 Accept 头的裸 HttpClient(ä¸èµ° ApiClient ä¿¡å°å°è£…,需è¦åŽŸå§‹çŠ¶æ€ç 与å“应体)。</summary>
private HttpClient CreateClient(String accept)
{
var client = _factory.CreateClient();
if (!String.IsNullOrEmpty(accept)) client.DefaultRequestHeaders.Accept.ParseAdd(accept);
return client;
}
private Boolean SkipWhenNoSpa()
{
if (SpaEmbedded) return false;
_output.WriteLine("跳过:程åºé›†æœªå†…嵌å‰ç«¯ï¼ˆæœªæ‰§è¡Œ pnpm build:all),纯åŽç«¯æž„å»ºä¸æ‰˜ç®¡ SPA");
return true;
}
[Fact]
public async Task GetRoot_ServesEmbeddedIndexHtml()
{
if (SkipWhenNoSpa()) return;
using var client = CreateClient("text/html");
var resp = await client.GetAsync("/");
var body = await resp.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Contains("text/html", resp.Content.Headers.ContentType!.MediaType, StringComparison.OrdinalIgnoreCase);
Assert.Contains("id=\"app\"", body);
// 构建期注入的 BUILD_ å ä½ç¬¦å¿…须被宿主替æ¢ä¸ºç©ºå€¼ï¼ˆåŒæºè¯·æ±‚),å¦åˆ™å‰ç«¯ä¼šè¯·æ±‚ä¼ªé€ ä¸»æœº
Assert.DoesNotContain("BUILD_REQUEST_BASE_URL", body);
// å…¥å£é¡µä¸ç¼“å˜ï¼šå‰ç«¯å‘布åŽåˆ·æ–°å³å¯åŠ è½½æ–°ç‰ˆæœ¬ï¼ˆä¸Žé”æ–¹çš®è‚¤è¡Œä¸ºä¸€è‡´ï¼‰
Assert.True(resp.Headers.CacheControl!.NoCache == true, "index.html 应带 Cache-Control: no-cache");
}
[Fact]
public async Task GetDeepLink_FallsBackToIndexHtml()
{
if (SkipWhenNoSpa()) return;
// History 路由深链:æœåŠ¡ç«¯æ²¡æœ‰ /app/overview/9 这个物ç†èµ„æºï¼Œåº”回退到 SPA å…¥å£ç”±å‰ç«¯è·¯ç”±æŽ¥ç®¡
using var client = CreateClient("text/html");
var resp = await client.GetAsync("/app/overview/9");
var body = await resp.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Contains("text/html", resp.Content.Headers.ContentType!.MediaType, StringComparison.OrdinalIgnoreCase);
Assert.Contains("id=\"app\"", body);
}
[Fact]
public async Task GetIndexHtml_ServedDirectly()
{
if (SkipWhenNoSpa()) return;
// çš®è‚¤é™æ€æ–‡ä»¶æä¾›è€…带å ä½ç¬¦è¡¥ä¸ï¼šç›´å‡ºå…¥å£é¡µåŒæ ·ä¸èƒ½æºå¸¦ BUILD_REQUEST_BASE_URL å—é¢é‡
using var client = CreateClient("text/html");
var resp = await client.GetAsync("/index.html");
var body = await resp.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Contains("text/html", resp.Content.Headers.ContentType!.MediaType, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("BUILD_REQUEST_BASE_URL", body);
}
[Fact]
public async Task GetUnknownApi_Keeps404()
{
if (SkipWhenNoSpa()) return;
// å³ä½¿æµè§ˆå™¨å¯¼èˆªï¼ˆAccept å« text/html),/api 未匹é…ä¹Ÿå¿…é¡»ä¿æŒ 404,ä¸èƒ½è¢« SPA å›žé€€æˆ index.html
using var client = CreateClient("text/html");
var resp = await client.GetAsync("/api/__NoSuchController__/__NoSuchAction__");
var body = await resp.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
Assert.DoesNotContain("id=\"app\"", body);
}
[Fact]
public async Task PostUnknownUiPath_Returns405()
{
if (SkipWhenNoSpa()) return;
// 回退端点ä¸å“åº”éž GET/HEAD:POST æ‰“åˆ°æœªåŒ¹é… UI 路径得到 405ï¼ˆæ— HTML 泄æ¼ï¼‰ï¼ŒWebhook ç‰æœºå™¨è¯·æ±‚打错地å€ä¸ä¼šè¯¯åž
using var client = CreateClient("text/html");
var resp = await client.PostAsync("/app/overview/9", new StringContent("{}", Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.MethodNotAllowed, resp.StatusCode);
Assert.DoesNotContain("id=\"app\"", body);
}
[Fact]
public async Task PostUnknownApi_Keeps404()
{
if (SkipWhenNoSpa()) return;
// POST æœªåŒ¹é… /api è·¯å¾„å— /api å›žé€€ä¿æŠ¤ï¼šå¿…é¡»æ‹¿åˆ° 404 è€Œä¸æ˜¯ HTML
using var client = CreateClient("text/html");
var resp = await client.PostAsync("/api/__NoSuchController__/__NoSuchAction__", new StringContent("{}", Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
Assert.DoesNotContain("id=\"app\"", body);
}
[Fact]
public async Task GetMissingAsset_Keeps404()
{
if (SkipWhenNoSpa()) return;
// 回退端点 {*path:nonfile} 约æŸï¼ˆå®žæµ‹ï¼‰ï¼šå¸¦æ‰©å±•å的缺失资æºä¸å›žé€€ï¼Œä¿æŒ 404——
// é¿å…把 HTML å½“è„šæœ¬è¿”å›žé€ æˆè¯æ³•é”™è¯¯ï¼ˆä¸Žè‡ªç ”ä¸é—´ä»¶çš„ Accept è¿‡æ»¤ç‰æ•ˆï¼‰
using var client = CreateClient("*/*");
var resp = await client.GetAsync("/assets/__missing_hash__/index.js");
var body = await resp.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
Assert.DoesNotContain("id=\"app\"", body);
}
}
|