Files
MealQuest/internal/frontend/frontend_test.go
T
dev_agentandClaude Opus 5.5 ee4670754a
Independent review Not signed off — 0 high, 1 medium, 1 low (#4)
Embed the PWA build and serve it with SPA fallback
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>
2026-10-02 22:29:41 -04:00

135 lines
4.0 KiB
Go

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)
}
}