Feature 01 task 3: Embed the PWA build with SPA fallback (#4)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #4.
This commit is contained in:
@@ -8,6 +8,11 @@ deploy/secrets/
|
||||
# Build artifacts and dependencies
|
||||
node_modules
|
||||
dist
|
||||
bin/
|
||||
# Placeholder so the Go embed compiles on a fresh clone; built assets stay ignored.
|
||||
!internal/frontend/dist/
|
||||
internal/frontend/dist/*
|
||||
!internal/frontend/dist/index.html
|
||||
|
||||
# Local databases
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Build entry point: build the PWA, embed it, then build the server binary.
|
||||
|
||||
FRONTEND_DIST := internal/frontend/dist
|
||||
|
||||
.PHONY: build web embed check-embed-clean
|
||||
|
||||
build: embed
|
||||
go build -o bin/mealquest ./cmd/server
|
||||
|
||||
web:
|
||||
cd web && npm ci && npm run build
|
||||
|
||||
# Replace the committed placeholder with the built PWA so go:embed picks it up.
|
||||
# Recreate the whole directory: a glob would skip dotfiles, which all:dist embeds.
|
||||
embed: web
|
||||
rm -rf $(FRONTEND_DIST)
|
||||
mkdir -p $(FRONTEND_DIST)
|
||||
cp -R web/dist/. $(FRONTEND_DIST)/
|
||||
|
||||
# Regression check: a stale hidden file must not survive an embed.
|
||||
check-embed-clean:
|
||||
touch $(FRONTEND_DIST)/.stale-embed-check
|
||||
$(MAKE) embed
|
||||
test ! -e $(FRONTEND_DIST)/.stale-embed-check
|
||||
+6
-1
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
)
|
||||
|
||||
@@ -37,9 +38,13 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
router, err := server.NewRouter(logger, version, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: server.NewRouter(logger, version),
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
||||
}
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Placeholder so the Go embed compiles. The build replaces this directory with web/dist. -->
|
||||
<p>MealQuest web assets have not been built.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package frontend serves the embedded PWA build with an SPA fallback.
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// embedded holds the web build. The build copies web/dist over the committed
|
||||
// placeholder index.html.
|
||||
//
|
||||
//go:embed all:dist
|
||||
var embedded embed.FS
|
||||
|
||||
const (
|
||||
indexFile = "index.html"
|
||||
|
||||
cacheImmutable = "public, max-age=31536000, immutable"
|
||||
cacheNoCache = "no-cache"
|
||||
)
|
||||
|
||||
// noCacheFiles must be revalidated on every load: they name the hashed assets
|
||||
// (or, for the service worker, control their caching), so a stale copy pins an
|
||||
// old release.
|
||||
var noCacheFiles = map[string]bool{
|
||||
indexFile: true,
|
||||
"sw.js": true,
|
||||
"manifest.webmanifest": true,
|
||||
}
|
||||
|
||||
// Assets returns the embedded web build rooted at its dist directory.
|
||||
func Assets() fs.FS {
|
||||
sub, err := fs.Sub(embedded, "dist")
|
||||
if err != nil {
|
||||
panic(err) // "dist" is a valid path, so Sub cannot fail
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// NewHandler returns a handler that serves files from fsys. A GET or HEAD for a
|
||||
// path that is not a file in fsys is answered with index.html so client-side
|
||||
// routes load the app. Paths equal to or under a reserved prefix (for example
|
||||
// "/api") are never answered with index.html, whether before or after the path
|
||||
// is cleaned; they are passed to notFound.
|
||||
// fsys must contain index.html.
|
||||
func NewHandler(fsys fs.FS, logger *slog.Logger, notFound http.Handler, reserved ...string) (http.Handler, error) {
|
||||
if _, err := fs.Stat(fsys, indexFile); err != nil {
|
||||
return nil, fmt.Errorf("frontend assets: %w", err)
|
||||
}
|
||||
return &handler{fsys: fsys, logger: logger, notFound: notFound, reserved: reserved}, nil
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
fsys fs.FS
|
||||
logger *slog.Logger
|
||||
notFound http.Handler
|
||||
reserved []string
|
||||
}
|
||||
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := path.Clean("/" + r.URL.Path)
|
||||
// Check the uncleaned path too, so dot segments cannot escape a reserved
|
||||
// prefix into the fallback ("/auth/../quests/42" cleans to "/quests/42").
|
||||
if h.isReserved(r.URL.Path) || h.isReserved(urlPath) {
|
||||
h.notFound.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(urlPath, "/")
|
||||
if name == "" || !h.isFile(name) {
|
||||
name = indexFile
|
||||
}
|
||||
h.serveFile(w, r, name)
|
||||
}
|
||||
|
||||
func (h *handler) isReserved(urlPath string) bool {
|
||||
for _, prefix := range h.reserved {
|
||||
if urlPath == prefix || strings.HasPrefix(urlPath, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *handler) isFile(name string) bool {
|
||||
info, err := fs.Stat(h.fsys, name)
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
|
||||
f, err := h.fsys.Open(name)
|
||||
if err != nil {
|
||||
h.fail(w, r, name, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
content, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
h.fail(w, r, name, errors.New("file does not implement io.Seeker"))
|
||||
return
|
||||
}
|
||||
|
||||
if noCacheFiles[name] {
|
||||
w.Header().Set("Cache-Control", cacheNoCache)
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", cacheImmutable)
|
||||
}
|
||||
if path.Ext(name) == ".webmanifest" {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
}
|
||||
// Embedded files carry no modification time; ServeContent omits
|
||||
// Last-Modified for the zero time.
|
||||
http.ServeContent(w, r, name, time.Time{}, content)
|
||||
}
|
||||
|
||||
func (h *handler) fail(w http.ResponseWriter, r *http.Request, name string, err error) {
|
||||
h.logger.ErrorContext(r.Context(), "serve frontend asset", "file", name, "error", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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')"
|
||||
robotsBody = "User-agent: *"
|
||||
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)},
|
||||
"robots.txt": {Data: []byte(robotsBody)},
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
{"unhashed asset", http.MethodGet, "/robots.txt", robotsBody, 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", "/auth/../quests/42", "/api%2F..%2Fquests/42"} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,44 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
// SPA's index.html.
|
||||
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
||||
|
||||
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||
// GET /health.
|
||||
func NewRouter(logger *slog.Logger, version string) http.Handler {
|
||||
// GET /health; assets is the web build served for every other GET.
|
||||
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
|
||||
r.Get("/health", health(logger, version))
|
||||
r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed"))
|
||||
r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found"))
|
||||
return r
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
})
|
||||
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
|
||||
// handler passes them to notFound rather than the SPA fallback.
|
||||
r.Method(http.MethodGet, "/*", web)
|
||||
r.Method(http.MethodHead, "/*", web)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
|
||||
+112
-15
@@ -7,14 +7,35 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureIndex = "<!doctype html><title>fixture index</title>"
|
||||
fixtureAsset = "console.log('fixture asset')"
|
||||
notFoundJSON = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
|
||||
)
|
||||
|
||||
func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := httptest.NewServer(NewRouter(logger, "1.2.3"))
|
||||
srv := httptest.NewServer(newTestRouter(t, "1.2.3"))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
resp, err := http.Get(srv.URL + "/health")
|
||||
@@ -42,9 +63,8 @@ func TestHealth(t *testing.T) {
|
||||
func TestHealthRejectsOtherMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
rec := httptest.NewRecorder()
|
||||
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", rec.Code)
|
||||
@@ -68,21 +88,98 @@ func TestHealthRejectsOtherMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathNotFound(t *testing.T) {
|
||||
func TestReservedPathsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
rec := httptest.NewRecorder()
|
||||
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", nil))
|
||||
router := newTestRouter(t, "dev")
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api"},
|
||||
{http.MethodGet, "/api/unknown"},
|
||||
{http.MethodPost, "/api/unknown"},
|
||||
{http.MethodGet, "/auth/callback"},
|
||||
{http.MethodGet, "/media/photo.jpg"},
|
||||
{http.MethodGet, "/health/deep"},
|
||||
{http.MethodGet, "/auth/../quests/42"},
|
||||
{http.MethodGet, "/api%2F..%2Fquests/42"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if got := rec.Body.String(); got != notFoundJSON {
|
||||
t.Errorf("body = %s, want %s", got, notFoundJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
|
||||
func TestFrontend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
tests := []struct {
|
||||
path string
|
||||
wantBody string
|
||||
wantCache string
|
||||
}{
|
||||
{"/", fixtureIndex, "no-cache"},
|
||||
{"/assets/index-AbC123.js", fixtureAsset, "public, max-age=31536000, immutable"},
|
||||
{"/quests/42", fixtureIndex, "no-cache"},
|
||||
{"/quests/42/edit", fixtureIndex, "no-cache"},
|
||||
}
|
||||
const want = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
|
||||
if got := rec.Body.String(); got != want {
|
||||
t.Errorf("body = %s, want %s", got, want)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, 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 TestHealthNotShadowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
|
||||
if body := rec.Body.String(); rec.Code != http.StatusOK || strings.Contains(body, fixtureIndex) {
|
||||
t.Errorf("GET /health = %d %q, want 200 health JSON", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontendRejectsOtherMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/quests/42", nil))
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), fixtureIndex) {
|
||||
t.Errorf("body = %q, want no index.html", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user