package api import ( "net/http" "net/http/httptest" "strings" "testing" "testing/fstest" "github.com/gin-gonic/gin" ) func testEngine() *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() // A real API route so unmatched /api/* paths reach NoRoute (as in production). r.GET("/api/v1/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) dist := fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte(`
`)}, "assets/app-abc123.js": &fstest.MapFile{Data: []byte(`console.log(1)`)}, } RegisterSPA(r, dist) return r } func do(t *testing.T, r *gin.Engine, method, path string) *httptest.ResponseRecorder { t.Helper() w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(method, path, nil)) return w } func TestSPAServesHashedAssetImmutable(t *testing.T) { w := do(t, testEngine(), http.MethodGet, "/assets/app-abc123.js") if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { t.Errorf("Cache-Control = %q, want immutable", cc) } if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { t.Errorf("Content-Type = %q, want javascript", ct) } } func TestSPAServesIndexNoCache(t *testing.T) { w := do(t, testEngine(), http.MethodGet, "/") if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if cc := w.Header().Get("Cache-Control"); cc != "no-cache" { t.Errorf("Cache-Control = %q, want no-cache", cc) } } func TestSPAFallbackForClientRoute(t *testing.T) { // A deep link with no matching file returns index.html so client routing works. w := do(t, testEngine(), http.MethodGet, "/gardens/42") if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if !strings.Contains(w.Body.String(), `id="root"`) { t.Errorf("expected index.html body, got %q", w.Body.String()) } } func TestSPAMissingAssetIs404(t *testing.T) { // A missing asset must NOT fall back to index.html (which would be a 200 of // HTML for a .js request — a MIME-type error in the browser). w := do(t, testEngine(), http.MethodGet, "/assets/missing-xyz.js") if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Code) } if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "json") { t.Errorf("Content-Type = %q, want json", ct) } } func TestSPAUnmatchedAPIIs404JSON(t *testing.T) { w := do(t, testEngine(), http.MethodGet, "/api/v1/nope") if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Code) } if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "json") { t.Errorf("Content-Type = %q, want json (not the SPA HTML)", ct) } } func TestSPARejectsNonGET(t *testing.T) { w := do(t, testEngine(), http.MethodPost, "/gardens") if w.Code != http.StatusMethodNotAllowed { t.Fatalf("status = %d, want 405", w.Code) } }