package spa import ( "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "code.nochebuena.dev/einherjar/contracts/logging" "code.nochebuena.dev/einherjar/core/logz" ) func discardLogger() logging.Logger { return logz.New(logz.Config{Writer: io.Discard}) } // newTestHandler builds a handler over a temp dir seeded with one file of each // caching class plus index.html. func newTestHandler(t *testing.T) http.Handler { t.Helper() dir := t.TempDir() for name, body := range map[string]string{ "index.html": "app", "main.4f8a2b1c.js": "console.log(1)", "favicon.ico": "icon", "ngsw.json": "{}", "manifest.webmanifest": `{"name":"app"}`, } { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } return NewHandler(discardLogger(), dir) } func get(h http.Handler, target, accept string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, target, nil) if accept != "" { req.Header.Set("Accept", accept) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestCacheControl(t *testing.T) { h := newTestHandler(t) cases := []struct { target, want string }{ {"/main.4f8a2b1c.js", "public, max-age=31536000, immutable"}, // content-hashed {"/favicon.ico", "public, max-age=3600"}, // verbatim, not hashed {"/ngsw.json", "no-cache"}, // service-worker manifest } for _, c := range cases { rec := get(h, c.target, "*/*") if rec.Code != http.StatusOK { t.Errorf("%s: status = %d, want 200", c.target, rec.Code) continue } if got := rec.Header().Get("Cache-Control"); got != c.want { t.Errorf("%s: Cache-Control = %q, want %q", c.target, got, c.want) } } } func TestIndexIsNoCache(t *testing.T) { // The SPA shell (served for a navigation route) must revalidate every load. rec := get(newTestHandler(t), "/dashboard", "text/html") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if got := rec.Header().Get("Cache-Control"); got != "no-cache" { t.Errorf("index Cache-Control = %q, want no-cache", got) } } func TestFallback_RouteServesIndex(t *testing.T) { rec := get(newTestHandler(t), "/deep/route", "text/html,application/xhtml+xml") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (index.html for a route)", rec.Code) } if body := rec.Body.String(); body == "" || body[0] != '<' { t.Errorf("expected index.html body, got %q", body) } } func TestFallback_MissingAssetIs404(t *testing.T) { // A missing file with an extension must not be masked as HTML. rec := get(newTestHandler(t), "/chunk.9f9f9f9f.js", "*/*") if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404 for a missing asset", rec.Code) } } func TestFallback_NonHTMLAcceptIs404(t *testing.T) { // A typed non-HTML client asking for a missing route gets 404, not the shell. rec := get(newTestHandler(t), "/api/thing", "application/json") if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404 for a non-HTML client", rec.Code) } } func TestFallback_RootServesIndex(t *testing.T) { rec := get(newTestHandler(t), "/", "text/html") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 for /", rec.Code) } } func TestManifestContentType(t *testing.T) { // Without the mime registration this is sniffed as text/plain; with it the // PWA manifest carries its registered type. rec := get(newTestHandler(t), "/manifest.webmanifest", "*/*") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") { t.Errorf("Content-Type = %q, want application/manifest+json", ct) } } func TestIsHashed(t *testing.T) { hashed := []string{"main.4f8a2b1c.js", "index-DkJf3x9a.js", "styles-4NDEUD5S.css", "app.a1b2c3d4.mjs"} plain := []string{"favicon.ico", "index.html", "logo.png", "about.js", "main.js", "vendor.css"} for _, n := range hashed { if !isHashed(n) { t.Errorf("isHashed(%q) = false, want true", n) } } for _, n := range plain { if isHashed(n) { t.Errorf("isHashed(%q) = true, want false", n) } } }