Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 9b66ba0ff5 Fix review findings F1-F3 for Docker context, health check and version
Independent review Approved — 0 high findings (#5)
F1: exclude generated internal/frontend/dist from the Docker build context.
F2: derive the health check host from MEALQUEST_HTTP_ADDR, mapping wildcard
hosts and localhost to 127.0.0.1 and keeping IPv6 brackets.
F3: compute the -dirty suffix ignoring the generated embed output, so a
rebuild of a clean checkout is not stamped dirty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:02:31 -04:00
dev_agentandClaude Opus 5.5 2e5724d150 Fix review findings F1 and F2 for the Dockerfile and .dockerignore
Independent review Not signed off — 0 high, 2 medium, 1 low (#5)
F1: derive the HEALTHCHECK port from MEALQUEST_HTTP_ADDR so an overridden
listen port is still probed at /health.
F2: make key/certificate exclusions recursive (**/*.pem, **/*.key) and add
**/*.p12, **/*.pfx, **/*.jks, **/*.keystore so nested local secrets never
enter the build context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:55:44 -04:00
dev_agentandClaude Opus 5.5 4dfc7baa2d Add Makefile dev/test/docker targets and multi-stage Dockerfile
Independent review Not signed off — 0 high, 2 medium (#5)
Makefile: dev runs the Go server (:8080) and Vite (:5173) together and
stops both on Ctrl-C; test runs the gofmt check, go vet, go test -race and
npm ci/typecheck/test; build stamps the version via -ldflags into a static
CGO_ENABLED=0 binary; docker builds the root Dockerfile. VERSION defaults
to git describe with a dev fallback.

Dockerfile: digest-pinned Node 22, Go 1.25 and Alpine 3.22 stages; the
runtime runs as uid 10001 with /data as a volume, EXPOSE 8080 and a
HEALTHCHECK on /health. .dockerignore keeps VCS, design, dependencies,
build output, databases and secrets out of the context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:50:16 -04:00
dev_agent 85569b451f 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>
2026-10-02 22:47:01 -04:00
dev_agent f0b2696059 Feature 01 task 2: Vite React TypeScript web skeleton (#3)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-02 22:26:34 -04:00
19 changed files with 3153 additions and 21 deletions
+26
View File
@@ -0,0 +1,26 @@
# Keep the build context small and free of secrets and local state.
.git
.gitignore
.dockerignore
Dockerfile
.remember/
.claude/
design/
**/.DS_Store
node_modules
web/node_modules
web/dist
# Generated embed output; the Go stage takes the PWA from the web stage.
internal/frontend/dist/
bin/
*.db
*.db-*
.env*
**/.env*
deploy/secrets/
**/*.pem
**/*.key
**/*.p12
**/*.pfx
**/*.jks
**/*.keystore
+5
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
# Base images are pinned by digest (multi-arch index); tags are for readability.
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 AS server
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Replace the placeholder with the built PWA; recreate the directory so no
# stale file survives into the embed.
RUN rm -rf internal/frontend/dist
COPY --from=web /src/web/dist/ internal/frontend/dist/
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
-o /out/mealquest ./cmd/server
# Alpine ships the CA bundle and busybox wget for the health check.
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
RUN addgroup -S -g 10001 mealquest \
&& adduser -S -D -H -u 10001 -G mealquest mealquest \
&& mkdir /data && chown mealquest:mealquest /data
COPY --from=server /out/mealquest /usr/local/bin/mealquest
ENV MEALQUEST_HTTP_ADDR=:8080 \
MEALQUEST_DATA_DIR=/data
VOLUME /data
EXPOSE 8080
USER 10001:10001
# Probe the listener from MEALQUEST_HTTP_ADDR: host is everything before the
# last colon (IPv6 keeps its brackets). Wildcard hosts map to loopback, as
# does localhost: Go listens on its IPv4 address but wget would try ::1.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD host="${MEALQUEST_HTTP_ADDR%:*}"; \
case "$host" in ""|0.0.0.0|"[::]"|localhost) host=127.0.0.1 ;; esac; \
wget -q -O /dev/null "http://${host}:${MEALQUEST_HTTP_ADDR##*:}/health" || exit 1
ENTRYPOINT ["/usr/local/bin/mealquest"]
+60
View File
@@ -0,0 +1,60 @@
# MealQuest build entry points.
#
# make dev Go server on :8080 plus Vite dev server on :5173 (proxies
# /api, /auth, /media to :8080). Needs the MEALQUEST_* env set.
# make test gofmt check, go vet, go test -race, npm typecheck and test.
# make build Build the PWA, embed it, build the static binary bin/mealquest.
# make docker Build the container image from ./Dockerfile.
FRONTEND_DIST := internal/frontend/dist
# Dirty means a tracked change outside the generated embed output, which
# `make embed` rewrites; plain `--dirty` would flag every rebuild.
GIT_DIRTY := $(shell git status --porcelain --untracked-files=no -- . ':(exclude)$(FRONTEND_DIST)' 2>/dev/null)
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(GIT_DIRTY),-dirty)
IMAGE ?= mealquest:$(VERSION)
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: dev test go-test web-test build web embed check-embed-clean docker
# Run both servers; Ctrl-C (or either exiting) stops both. The binary is built
# first and run directly because `go run` would orphan its child on kill.
dev: SHELL := /bin/bash
dev:
go build -ldflags "$(LDFLAGS)" -o bin/mealquest-dev ./cmd/server
@trap 'kill $$(jobs -p) 2>/dev/null; wait' EXIT; \
bin/mealquest-dev & \
(cd web && exec npm run dev) & \
wait -n
test: go-test web-test
go-test:
@unformatted="$$(gofmt -l .)"; \
if [ -n "$$unformatted" ]; then echo "gofmt needed:"; echo "$$unformatted"; exit 1; fi
go vet ./...
go test -race ./...
web-test:
cd web && npm ci && npm run typecheck && npm test
build: embed
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -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
docker:
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) .
+6 -1
View File
@@ -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),
}
+11
View File
@@ -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>
+127
View File
@@ -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)
}
+137
View File
@@ -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)
}
}
+29 -5
View File
@@ -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
View File
@@ -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())
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MealQuest</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2451
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "mealquest-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "^5.104.1",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^7.18.4"
},
"devDependencies": {
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vitest": "^5.0.3"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, expect, it } from 'vitest'
import App from './App.tsx'
describe('App', () => {
it('renders the MealQuest shell', () => {
render(
<MemoryRouter>
<App />
</MemoryRouter>,
)
expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument()
})
})
+25
View File
@@ -0,0 +1,25 @@
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Route, Routes } from 'react-router'
// Placeholder shell only: no product UI, auth or API calls yet.
function Shell() {
return (
<main>
<h1>MealQuest</h1>
</main>
)
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
export default function App() {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="*" element={<Shell />} />
</Routes>
</QueryClientProvider>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App.tsx'
const root = document.getElementById('root')
if (!root) {
throw new Error('MealQuest: #root element missing from index.html')
}
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
+7
View File
@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
const backend = 'http://localhost:8080'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': backend,
'/auth': backend,
'/media': backend,
},
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})