Files
QiuSWandClaude Opus 5.5 0b01b92c98 fix(server): serve the Admin SPA at / when dist exists (#346)
go-admin registered its welcome page on GET / in every non-prod mode, so a
reverse proxy that forwarded / to the server showed 「GO-ADMIN欢迎您」
instead of the Admin (happened after the 2026-09-28 server migration).
When dist/index.html exists, / now returns it; without a dist (vite
development) the previous welcome/prod behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 16:07:49 +08:00

137 lines
4.9 KiB
Go

package router
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func newSPAEngine(t *testing.T, withDist bool) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
dist := filepath.Join(t.TempDir(), "dist")
if withDist {
if err := os.MkdirAll(filepath.Join(dist, "assets"), 0o755); err != nil {
t.Fatalf("建目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>SPA"), 0o644); err != nil {
t.Fatalf("写 index.html 失败: %v", err)
}
}
t.Setenv(SPADirEnv, dist)
engine := gin.New()
engine.GET("/api/admin/v1/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") })
InitSPARouter(engine)
return engine
}
func do(engine *gin.Engine, method, path string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(method, path, nil))
return recorder
}
// history 模式:前端路由必须回退到 index.html,否则刷新页面就是 404。
func TestUnmatchedGetFallsBackToIndex(t *testing.T) {
engine := newSPAEngine(t, true)
response := do(engine, http.MethodGet, "/goauto/syb-products")
if response.Code != http.StatusOK {
t.Fatalf("前端路由应回退到 index.html,实际 %d", response.Code)
}
if body := response.Body.String(); body != "<!doctype html>SPA" {
t.Fatalf("返回的不是 index.html: %q", body)
}
}
// `[必须]` 写错的 API 路径必须还是 404。回一个 200 + HTML,客户端看到的会是
// JSON 解析错误,而不是"这个接口不存在"。
func TestUnknownAPIPathStaysA404(t *testing.T) {
engine := newSPAEngine(t, true)
for _, path := range []string{
"/api/admin/v1/does-not-exist",
"/api/v1/nope",
"/swagger/admin/index.html",
"/static/missing.png",
"/ws/1/2",
"/info",
} {
if response := do(engine, http.MethodGet, path); response.Code != http.StatusNotFound {
t.Fatalf("%s 应返回 404,实际 %d", path, response.Code)
}
}
}
// 真实存在的接口不能被 SPA 回退影响。
func TestRegisteredAPIStillWorks(t *testing.T) {
engine := newSPAEngine(t, true)
if response := do(engine, http.MethodGet, "/api/admin/v1/ping"); response.Code != http.StatusOK {
t.Fatalf("已注册的接口被影响了: %d", response.Code)
}
}
// 非 GET 请求不该拿到 HTML——POST 到不存在的路径就是 404。
func TestNonGetDoesNotFallBack(t *testing.T) {
engine := newSPAEngine(t, true)
if response := do(engine, http.MethodPost, "/whatever"); response.Code != http.StatusNotFound {
t.Fatalf("POST 不应回退到 index.html,实际 %d", response.Code)
}
}
// 开发环境没有 dist,绝不能注册 NoRoute——否则每个真 404 都变成一张 HTML。
func TestWithoutDistNoFallbackIsInstalled(t *testing.T) {
engine := newSPAEngine(t, false)
if response := do(engine, http.MethodGet, "/goauto/syb-products"); response.Code != http.StatusNotFound {
t.Fatalf("没有 dist 时不应回退,实际 %d", response.Code)
}
if response := do(engine, http.MethodGet, "/api/admin/v1/ping"); response.Code != http.StatusOK {
t.Fatalf("没有 dist 时接口仍应正常: %d", response.Code)
}
}
// #346: with a built frontend, `/` must be the Admin SPA — never go-admin's
// welcome page, even in non-prod modes where the welcome page used to own `/`.
func TestRootServesSPAWhenDistExists(t *testing.T) {
gin.SetMode(gin.TestMode)
dist := filepath.Join(t.TempDir(), "dist")
if err := os.MkdirAll(dist, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>SPA"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(SPADirEnv, dist)
for _, mode := range []string{"dev", "test", "prod"} {
engine := gin.New()
registerRootRoute(engine, mode, func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") })
InitSPARouter(engine)
response := do(engine, http.MethodGet, "/")
if response.Code != http.StatusOK || response.Body.String() != "<!doctype html>SPA" {
t.Fatalf("mode=%s: / should serve index.html, got %d %q", mode, response.Code, response.Body.String())
}
}
}
// Without a dist (development under vite) the previous behaviour is kept:
// welcome page outside prod, nothing registered in prod.
func TestRootWithoutDistKeepsPreviousBehaviour(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv(SPADirEnv, filepath.Join(t.TempDir(), "missing-dist"))
welcome := func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") }
dev := gin.New()
registerRootRoute(dev, "dev", welcome)
if response := do(dev, http.MethodGet, "/"); response.Code != http.StatusOK || response.Body.String() != "GO-ADMIN欢迎您" {
t.Fatalf("dev without dist should keep the welcome page, got %d %q", response.Code, response.Body.String())
}
prod := gin.New()
registerRootRoute(prod, "prod", welcome)
if response := do(prod, http.MethodGet, "/"); response.Code != http.StatusNotFound {
t.Fatalf("prod without dist should not register /, got %d", response.Code)
}
}