Files
team-api/internal/cmd/spa_handler_test.go
T

142 lines
4.6 KiB
Go

package cmd
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
// 构造模拟的前端构建产物:入口文件 + 一个带 hash 的静态 chunk
func newSpaTestFS() fs.FS {
return fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte("<!doctype html><html><head><script src=\"/assets/index-x9y8z7.js\"></script></head><body></body></html>"),
},
"assets/index-x9y8z7.js": &fstest.MapFile{Data: []byte("console.log('chunk')")},
"favicon.ico": &fstest.MapFile{Data: []byte("ico")},
"screenshots/page-a.webp": &fstest.MapFile{Data: []byte("img")},
}
}
func get(t *testing.T, h http.HandlerFunc, target string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
func assertContains(t *testing.T, rec *httptest.ResponseRecorder, key, want string) {
t.Helper()
if got := rec.Header().Get(key); got != want {
t.Errorf("%s = %q, 期望 %q", key, got, want)
}
}
// 路由请求回退 index.html,且带 no-cache 与 ETag
func TestSpaHandlerRouteFallback(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
rec := get(t, h, "/dashboard", nil)
if rec.Code != http.StatusOK {
t.Fatalf("状态码 = %d, 期望 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "index-x9y8z7.js") {
t.Error("响应体应为 index.html 内容")
}
assertContains(t, rec, "Cache-Control", "no-cache")
if rec.Header().Get("ETag") == "" {
t.Error("index.html 应携带 ETag")
}
}
// 根路径与显式 /index.html 都走同一入口响应
func TestSpaHandlerIndexPaths(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
for _, target := range []string{"/", "/index.html"} {
rec := get(t, h, target, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<!doctype html>") {
t.Errorf("%s 应返回 200 的 index.html,实际 %d", target, rec.Code)
}
}
}
// 未重新发布时重复访问命中 ETag 协商缓存,返回 304 空响应
func TestSpaHandlerEtagNegotiation(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
first := get(t, h, "/", nil)
tag := first.Header().Get("ETag")
rec := get(t, h, "/dashboard", map[string]string{"If-None-Match": tag})
if rec.Code != http.StatusNotModified {
t.Fatalf("状态码 = %d, 期望 304 Not Modified", rec.Code)
}
if rec.Body.Len() != 0 {
t.Error("304 响应不应携带 body")
}
}
// 带静态资源特征的缺失文件返回 404 而非回退 SPA HTML(发版后旧 hash chunk 场景)
func TestSpaHandlerMissingAssetReturns404(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
cases := []string{
"/assets/stale-old-hash.js",
"/assets/some.css",
"/assets/vendor.wasm",
"/images/missing.png", // 不存在的资源扩展名
"/manifest.json",
}
for _, target := range cases {
rec := get(t, h, target, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("%s 应返回 404,实际 %d", target, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); strings.Contains(ct, "text/html") {
t.Errorf("%s 的 404 不应是 text/html", target)
}
}
}
// 无扩展名的未知路径仍回退 index.html(深层路由刷新场景)
func TestSpaHandlerUnknownRouteStillFallbacks(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
rec := get(t, h, "/team/member-detail/42", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "index") {
t.Fatalf("深层路由应回退 index.html,实际 %d", rec.Code)
}
}
// 存在的 assets 文件命中后强缓存一年;根级非 hash 文件要求协商校验
func TestSpaHandlerCacheHeaders(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
rec := get(t, h, "/assets/index-x9y8z7.js", nil)
assertContains(t, rec, "Cache-Control", "public, max-age=31536000, immutable")
rec = get(t, h, "/favicon.ico", nil)
assertContains(t, rec, "Cache-Control", "no-cache")
}
// 带 URL 前缀的 admin 子应用:前缀剥离、前缀下的 404 与缓存规则同样生效
func TestSpaHandlerWithPrefix(t *testing.T) {
h := spaHandler(newSpaTestFS(), "/admin")
rec := get(t, h, "/admin/", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<!doctype html>") {
t.Fatalf("/admin/ 应返回 index.html,实际 %d", rec.Code)
}
rec = get(t, h, "/admin/assets/missing.js", nil)
if rec.Code != http.StatusNotFound {
t.Errorf("/admin/assets/missing.js 应返回 404,实际 %d", rec.Code)
}
rec = get(t, h, "/admin/assets/index-x9y8z7.js", nil)
assertContains(t, rec, "Cache-Control", "public, max-age=31536000, immutable")
}