Embed the PWA build and serve it with SPA fallback
Independent review Not signed off — 0 high, 1 medium, 1 low (#4)
Independent review Not signed off — 0 high, 1 medium, 1 low (#4)
Add internal/frontend, which embeds internal/frontend/dist and serves it with Cache-Control "no-cache" for index.html, sw.js and manifest.webmanifest and "public, max-age=31536000, immutable" for every other asset. Any other GET or HEAD falls back to index.html, except paths under the reserved /api, /auth, /media and /health prefixes, which get the JSON not_found error instead. NewRouter now takes the asset fs.FS so tests use a fstest.MapFS fixture, mounts an /api subrouter with JSON 404/405 handlers, and fails fast if the assets lack index.html. A placeholder dist/index.html is tracked so a fresh clone compiles; built assets remain git-ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
const (
|
||||
indexBody = "<!doctype html><title>fixture index</title>"
|
||||
assetBody = "console.log('fixture asset')"
|
||||
swBody = "self.addEventListener('fetch', () => {})"
|
||||
manifestBody = `{"name":"MealQuest"}`
|
||||
notFoundBody = "reserved not found"
|
||||
)
|
||||
|
||||
func fixture() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(indexBody)},
|
||||
"sw.js": {Data: []byte(swBody)},
|
||||
"manifest.webmanifest": {Data: []byte(manifestBody)},
|
||||
"assets/index-AbC123.js": {Data: []byte(assetBody)},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
notFound := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, notFoundBody, http.StatusNotFound)
|
||||
})
|
||||
h, err := NewHandler(fixture(), logger, notFound, "/api", "/auth", "/media", "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestServe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantBody string
|
||||
wantCache string
|
||||
}{
|
||||
{"hashed asset", http.MethodGet, "/assets/index-AbC123.js", assetBody, cacheImmutable},
|
||||
{"root", http.MethodGet, "/", indexBody, cacheNoCache},
|
||||
{"index.html", http.MethodGet, "/index.html", indexBody, cacheNoCache},
|
||||
{"service worker", http.MethodGet, "/sw.js", swBody, cacheNoCache},
|
||||
{"manifest", http.MethodGet, "/manifest.webmanifest", manifestBody, cacheNoCache},
|
||||
{"client route", http.MethodGet, "/quests/42", indexBody, cacheNoCache},
|
||||
{"missing asset", http.MethodGet, "/assets/missing.js", indexBody, cacheNoCache},
|
||||
{"directory", http.MethodGet, "/assets", indexBody, cacheNoCache},
|
||||
{"dot segments", http.MethodGet, "/x/../../sw.js", swBody, cacheNoCache},
|
||||
{"prefix lookalike", http.MethodGet, "/apiary", indexBody, cacheNoCache},
|
||||
{"head", http.MethodHead, "/assets/index-AbC123.js", "", cacheImmutable},
|
||||
}
|
||||
h := newTestHandler(t)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != tt.wantBody {
|
||||
t.Errorf("body = %q, want %q", got, tt.wantBody)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
|
||||
t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
newTestHandler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil))
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); got != "application/manifest+json" {
|
||||
t.Errorf("Content-Type = %q, want application/manifest+json", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedPrefixesNotServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandler(t)
|
||||
for _, path := range []string{"/api", "/api/unknown", "/auth/login", "/media/x.jpg", "/health", "/health/deep", "/x/../api/unknown"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != notFoundBody+"\n" {
|
||||
t.Errorf("body = %q, want the notFound handler's body", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHandlerRequiresIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
fsys := fstest.MapFS{"sw.js": {Data: []byte(swBody)}}
|
||||
if _, err := NewHandler(fsys, logger, http.NotFoundHandler()); err == nil {
|
||||
t.Fatal("NewHandler without index.html: want error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsContainsIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewHandler(Assets(), logger, http.NotFoundHandler()); err != nil {
|
||||
t.Fatalf("embedded assets: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user