package ginx import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "testing/fstest" "github.com/gin-gonic/gin" ) func TestDirHandlerWithSPA(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("spa"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "manifest"), []byte("manifest content"), 0o644); err != nil { t.Fatal(err) } tests := []struct { path string wantStatus int wantBody string }{ {path: "/assets/not-exists.css", wantStatus: http.StatusOK, wantBody: "spa"}, {path: "/images/not-exists.png", wantStatus: http.StatusOK, wantBody: "spa"}, {path: "/fonts/not-exists", wantStatus: http.StatusOK, wantBody: "spa"}, {path: "/manifest", wantStatus: http.StatusOK, wantBody: "manifest content"}, {path: "/support", wantStatus: http.StatusOK, wantBody: "spa"}, } handler := DirHandler(http.Dir(root), DirOptions{ ShowList: false, SPA: true, IndexName: "index.html", }) for _, tt := range tests { rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) ctx.Request = httptest.NewRequest(http.MethodGet, tt.path, nil) handler(ctx) if rec.Code != tt.wantStatus { t.Fatalf("%s status=%d want %d; body=%q", tt.path, rec.Code, tt.wantStatus, rec.Body.String()) } if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody { t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody) } } } func TestStaticFilesDoesNotOpenDirectories(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644); err != nil { t.Fatal(err) } fileSystem := StaticFiles(root) if file, err := fileSystem.Open("/file.txt"); err != nil { t.Fatalf("Open(file.txt) err=%v", err) } else { _ = file.Close() } if file, err := fileSystem.Open("/"); err == nil { _ = file.Close() t.Fatal("Open(/) succeeded, want error") } else if !os.IsNotExist(err) { t.Fatalf("Open(/) err=%v, want not exist", err) } } func TestHandleSPAKeepsNotFoundPrefixes(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("spa"), 0o644); err != nil { t.Fatal(err) } engine := gin.New() HandleSPA(engine, SPAOptions{ Root: root, DirOptions: DirOptions{ ShowList: false, SPA: true, IndexName: "index.html", }, NotFoundPrefixes: []string{"/api/"}, }) tests := []struct { path string wantStatus int wantBody string }{ {path: "/", wantStatus: http.StatusOK, wantBody: "spa"}, {path: "/support", wantStatus: http.StatusOK, wantBody: "spa"}, {path: "/api/not-exists", wantStatus: http.StatusNotFound}, } for _, tt := range tests { rec := httptest.NewRecorder() engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) if rec.Code != tt.wantStatus { t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus) } if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody { t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody) } } } func TestHandleSPARootRedirect(t *testing.T) { // Mirrors the real export: no index.html at the root, only the exported // route shells. engine := gin.New() HandleSPA(engine, SPAOptions{ EmbeddedFS: fstest.MapFS{ "dashboard.html": {Data: []byte("dashboard")}, "support.html": {Data: []byte("support")}, }, EmbeddedRoot: ".", RootRedirect: "/support", DirOptions: DirOptions{ShowList: false, SPA: true, IndexName: "index.html"}, NotFoundPrefixes: []string{"/api/"}, }) t.Run("root redirects", func(t *testing.T) { for _, method := range []string{http.MethodGet, http.MethodHead} { rec := httptest.NewRecorder() engine.ServeHTTP(rec, httptest.NewRequest(method, "/", nil)) if rec.Code != http.StatusFound { t.Fatalf("%s / status=%d want %d", method, rec.Code, http.StatusFound) } if location := rec.Header().Get("Location"); location != "/support" { t.Fatalf("%s / Location=%q want %q", method, location, "/support") } } }) t.Run("other routes still serve the SPA", func(t *testing.T) { rec := httptest.NewRecorder() engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/support", nil)) if rec.Code != http.StatusOK { t.Fatalf("/support status=%d want %d", rec.Code, http.StatusOK) } if body := strings.TrimSpace(rec.Body.String()); body != "support" { t.Fatalf("/support body=%q", body) } }) t.Run("api 404 is unaffected", func(t *testing.T) { rec := httptest.NewRecorder() engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/not-exists", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("/api/not-exists status=%d want %d", rec.Code, http.StatusNotFound) } }) } func TestDirHandlerWithNextExportPages(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("home"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "dashboard.html"), []byte("dashboard"), 0o644); err != nil { t.Fatal(err) } dashboardDir := filepath.Join(root, "dashboard") if err := os.MkdirAll(dashboardDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dashboardDir, "login.html"), []byte("login"), 0o644); err != nil { t.Fatal(err) } handler := DirHandler(http.Dir(root), DirOptions{ ShowList: false, SPA: true, IndexName: "index.html", }) tests := []struct { path string wantStatus int wantBody string }{ {path: "/", wantStatus: http.StatusOK, wantBody: "home"}, {path: "/dashboard", wantStatus: http.StatusOK, wantBody: "dashboard"}, {path: "/dashboard/", wantStatus: http.StatusOK, wantBody: "dashboard"}, {path: "/dashboard/login", wantStatus: http.StatusOK, wantBody: "login"}, {path: "/dashboard/login/", wantStatus: http.StatusOK, wantBody: "login"}, {path: "/other-spa-route", wantStatus: http.StatusOK, wantBody: "home"}, } for _, tt := range tests { rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) ctx.Request = httptest.NewRequest(http.MethodGet, tt.path, nil) handler(ctx) if rec.Code != tt.wantStatus { t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus) } if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody { t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody) } } }