package webui import ( "io" "net/http" "net/http/httptest" "strings" "testing" "testing/fstest" ) // get drives the handler with a GET for the given path and returns the response. func get(t *testing.T, h http.Handler, target string) *http.Response { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) return rec.Result() } func body(t *testing.T, resp *http.Response) string { t.Helper() b, _ := io.ReadAll(resp.Body) return string(b) } // TestShellNoCache: the served HTML shell carries no-cache so a new deploy's // shell (and the asset URLs it references) is fetched fresh. func TestShellNoCache(t *testing.T) { h := Handler("/app/", "index.html") if cc := get(t, h, "/app/").Header.Get("Cache-Control"); cc != "no-cache" { t.Errorf("shell Cache-Control = %q, want no-cache", cc) } } // TestAppMountServesShellStripsPrefixAndCachesAssets: "/app/" and "/telegram/" serve the app // shell (index.html), strip their prefix, fall back for deep links, and mark hash-named // assets immutable. func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) { for _, prefix := range []string{"/app/", "/telegram/"} { h := Handler(prefix, "index.html") if resp := get(t, h, prefix); resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-app-shell") { t.Fatalf("GET %s did not serve the app shell (status %d)", prefix, resp.StatusCode) } // A deep link falls back to the shell so the hash router can take over. if resp := get(t, h, prefix+"game/abc"); resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-app-shell") { t.Fatalf("GET %sgame/abc did not fall back to the app shell (status %d)", prefix, resp.StatusCode) } // A hash-named asset is served directly and marked immutable. resp := get(t, h, prefix+"assets/.gitkeep") if resp.StatusCode != http.StatusOK { t.Fatalf("GET %sassets/.gitkeep status = %d, want 200", prefix, resp.StatusCode) } if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "immutable") { t.Errorf("asset Cache-Control = %q, want immutable", cc) } } } // testFS mirrors what Vite emits into dist/: the SPA shell, the PWA manifest + service worker at // the root (copied from ui/public), and a hash-named asset. The compile-time embedded dist/ holds // only a placeholder shell, so the manifest/sw.js serving is exercised over this in-memory FS. func testFS() fstest.MapFS { return fstest.MapFS{ "index.html": {Data: []byte("shell")}, "manifest.webmanifest": {Data: []byte(`{"name":"Эрудит"}`)}, "sw.js": {Data: []byte("self.addEventListener('fetch', () => {});")}, "assets/app-abc123.js": {Data: []byte("export const x = 1;")}, } } // TestHandlerServesManifestWithManifestType: the PWA manifest is served with the manifest MIME // type (registered in init, since the distroless image has no /etc/mime.types), not sniffed to // text/plain, which some browsers reject. func TestHandlerServesManifestWithManifestType(t *testing.T) { h := handlerFor(testFS(), "/app/", "index.html") resp := get(t, h, "/app/manifest.webmanifest") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") { t.Errorf("Content-Type = %q, want application/manifest+json", ct) } } // TestHandlerServesServiceWorkerAsJavaScript: sw.js is served as JavaScript from the root, so its // registration (scope /app/) succeeds. func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) { h := handlerFor(testFS(), "/app/", "index.html") resp := get(t, h, "/app/sw.js") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { t.Errorf("Content-Type = %q, want text/javascript", ct) } } // TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted // into dist/ falls back to the SPA shell as text/html, on which SW registration would fail. The two // tests above pin that the real files are served instead of the shell. func TestHandlerFallsBackToShellForUnknownManifestPath(t *testing.T) { h := handlerFor(testFS(), "/app/", "index.html") resp := get(t, h, "/app/not-emitted.webmanifest") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("Content-Type = %q, want text/html (SPA shell fallback)", ct) } }